diff --git a/.air.toml b/.air.toml index 8854041a25b..6e3c5bdc83b 100644 --- a/.air.toml +++ b/.air.toml @@ -4,7 +4,7 @@ tmp_dir = ".air" [build] pre_cmd = ["killall -9 gitea 2>/dev/null || true"] # kill off potential zombie processes from previous runs cmd = "make --no-print-directory backend" -bin = "gitea" +entrypoint = ["./gitea"] delay = 2000 include_ext = ["go", "tmpl"] include_file = ["main.go"] diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 0b20da7c160..4f82a5d8c6a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -13,7 +13,7 @@ "ghcr.io/devcontainers/features/git-lfs:1.2.5": {}, "ghcr.io/jsburckhardt/devcontainer-features/uv:1": {}, "ghcr.io/devcontainers/features/python:1": { - "version": "3.13" + "version": "3.14" }, "ghcr.io/warrenbuckley/codespace-features/sqlite:1": {} }, diff --git a/.dockerignore b/.dockerignore index 8e0d6b36665..c88fb144fe5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -74,6 +74,9 @@ cpu.out /VERSION /.air /.go-licenses +/Dockerfile +/Dockerfile.rootless +/.venv # Files and folders that were previously generated /public/assets/img/webpack diff --git a/.editorconfig b/.editorconfig index 13aa8d50f01..bf1cf757cc6 100644 --- a/.editorconfig +++ b/.editorconfig @@ -25,6 +25,10 @@ insert_final_newline = false [templates/user/auth/oidc_wellknown.tmpl] indent_style = space +[templates/shared/actions/runner_badge_*.tmpl] +# editconfig lint requires these XML-like files to have charset defined, but the files don't have. +charset = unset + [Makefile] indent_style = tab diff --git a/.gitattributes b/.gitattributes index e218bbe25df..afd02555f59 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,3 +8,4 @@ /vendor/** -text -eol linguist-vendored /web_src/js/vendor/** -text -eol linguist-vendored Dockerfile.* linguist-language=Dockerfile +Makefile.* linguist-language=Makefile diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000000..be33b8975fc --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 + +updates: + - package-ecosystem: github-actions + labels: [modifies/dependencies] + directory: / + schedule: + interval: daily + cooldown: + default-days: 5 diff --git a/.github/labeler.yml b/.github/labeler.yml index 49679d28cf1..68a0f30fd64 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -46,12 +46,11 @@ modifies/internal: - ".gitpod.yml" - ".markdownlint.yaml" - ".spectral.yaml" - - "stylelint.config.js" + - "stylelint.config.*" - ".yamllint.yaml" - ".github/**" - ".gitea/**" - ".devcontainer/**" - - "build.go" - "build/**" - "contrib/**" @@ -85,9 +84,9 @@ docs-update-needed: topic/code-linting: - changed-files: - any-glob-to-any-file: - - ".eslintrc.cjs" - ".golangci.yml" - ".markdownlint.yaml" - ".spectral.yaml" - ".yamllint.yaml" - - "stylelint.config.js" + - "eslint*.config.*" + - "stylelint.config.*" diff --git a/.github/workflows/cron-flake-updater.yml b/.github/workflows/cron-flake-updater.yml new file mode 100644 index 00000000000..105802e5587 --- /dev/null +++ b/.github/workflows/cron-flake-updater.yml @@ -0,0 +1,22 @@ +name: cron-flake-updater + +on: + workflow_dispatch: + schedule: + - cron: '0 0 * * 0' # runs weekly on Sunday at 00:00 + +jobs: + nix-flake-update: + permissions: + contents: write + issues: write + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: DeterminateSystems/determinate-nix-action@v3 + - uses: DeterminateSystems/update-flake-lock@main + with: + pr-title: "Update Nix flake" + pr-labels: | + dependencies diff --git a/.github/workflows/cron-licenses.yml b/.github/workflows/cron-licenses.yml index c34066d3187..ee1c3e0c750 100644 --- a/.github/workflows/cron-licenses.yml +++ b/.github/workflows/cron-licenses.yml @@ -9,16 +9,18 @@ jobs: cron-licenses: runs-on: ubuntu-latest if: github.repository == 'go-gitea/gitea' + permissions: + contents: write steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true - run: make generate-gitignore timeout-minutes: 40 - name: push translations to repo - uses: appleboy/git-push-action@v0.0.3 + uses: appleboy/git-push-action@v1.2.0 with: author_email: "teabot@gitea.io" author_name: GiteaBot diff --git a/.github/workflows/cron-translations.yml b/.github/workflows/cron-translations.yml index f1b51debf12..56a30fb5ba6 100644 --- a/.github/workflows/cron-translations.yml +++ b/.github/workflows/cron-translations.yml @@ -9,9 +9,11 @@ jobs: crowdin-pull: runs-on: ubuntu-latest if: github.repository == 'go-gitea/gitea' + permissions: + contents: write steps: - - uses: actions/checkout@v4 - - uses: crowdin/github-action@v1 + - uses: actions/checkout@v6 + - uses: crowdin/github-action@v2 with: upload_sources: true upload_translations: false @@ -27,7 +29,7 @@ jobs: - name: update locales run: ./build/update-locales.sh - name: push translations to repo - uses: appleboy/git-push-action@v0.0.3 + uses: appleboy/git-push-action@v1.2.0 with: author_email: "teabot@gitea.io" author_name: GiteaBot diff --git a/.github/workflows/files-changed.yml b/.github/workflows/files-changed.yml index edceef0092b..332e9e0d6ff 100644 --- a/.github/workflows/files-changed.yml +++ b/.github/workflows/files-changed.yml @@ -19,11 +19,15 @@ on: value: ${{ jobs.detect.outputs.swagger }} yaml: value: ${{ jobs.detect.outputs.yaml }} + json: + value: ${{ jobs.detect.outputs.json }} jobs: detect: runs-on: ubuntu-latest timeout-minutes: 3 + permissions: + contents: read outputs: backend: ${{ steps.changes.outputs.backend }} frontend: ${{ steps.changes.outputs.frontend }} @@ -33,8 +37,9 @@ jobs: docker: ${{ steps.changes.outputs.docker }} swagger: ${{ steps.changes.outputs.swagger }} yaml: ${{ steps.changes.outputs.yaml }} + json: ${{ steps.changes.outputs.json }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: dorny/paths-filter@v3 id: changes with: @@ -48,7 +53,7 @@ jobs: - "Makefile" - ".golangci.yml" - ".editorconfig" - - "options/locale/locale_en-US.ini" + - "options/locale/locale_en-US.json" frontend: - "*.js" @@ -80,6 +85,7 @@ jobs: - "uv.lock" docker: + - ".github/workflows/pull-docker-dryrun.yml" - "Dockerfile" - "Dockerfile.rootless" - "docker/**" @@ -98,3 +104,6 @@ jobs: - "**/*.yaml" - ".yamllint.yaml" - "pyproject.toml" + + json: + - "**/*.json" diff --git a/.github/workflows/pull-compliance.yml b/.github/workflows/pull-compliance.yml index 6f8991ed4ee..fb81622bd69 100644 --- a/.github/workflows/pull-compliance.yml +++ b/.github/workflows/pull-compliance.yml @@ -10,14 +10,18 @@ concurrency: jobs: files-changed: uses: ./.github/workflows/files-changed.yml + permissions: + contents: read lint-backend: if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true @@ -30,14 +34,18 @@ jobs: if: needs.files-changed.outputs.templates == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v6 - - run: uv python install 3.12 + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v7 + - run: uv python install 3.14 - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v6 with: node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml - run: make deps-py - run: make deps-frontend - run: make lint-templates @@ -46,23 +54,44 @@ jobs: if: needs.files-changed.outputs.yaml == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v6 - - run: uv python install 3.12 + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v7 + - run: uv python install 3.14 - run: make deps-py - run: make lint-yaml + lint-json: + if: needs.files-changed.outputs.json == 'true' + needs: files-changed + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v5 + with: + node-version: 24 + - run: make deps-frontend + - run: make lint-json + lint-swagger: if: needs.files-changed.outputs.swagger == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v6 with: node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml - run: make deps-frontend - run: make lint-swagger @@ -70,9 +99,11 @@ jobs: if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.frontend == 'true' || needs.files-changed.outputs.actions == 'true' || needs.files-changed.outputs.docs == 'true' || needs.files-changed.outputs.templates == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true @@ -82,9 +113,11 @@ jobs: if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true @@ -99,9 +132,11 @@ jobs: if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true @@ -114,9 +149,11 @@ jobs: if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true @@ -127,12 +164,16 @@ jobs: if: needs.files-changed.outputs.frontend == 'true' || needs.files-changed.outputs.actions == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v6 with: node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml - run: make deps-frontend - run: make lint-frontend - run: make checks-frontend @@ -143,9 +184,11 @@ jobs: if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true @@ -175,12 +218,16 @@ jobs: if: needs.files-changed.outputs.docs == 'true' || needs.files-changed.outputs.actions == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v6 with: node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml - run: make deps-frontend - run: make lint-md @@ -188,9 +235,11 @@ jobs: if: needs.files-changed.outputs.actions == 'true' || needs.files-changed.outputs.actions == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true diff --git a/.github/workflows/pull-db-tests.yml b/.github/workflows/pull-db-tests.yml index faf6a90e1bc..d168c2ecc5f 100644 --- a/.github/workflows/pull-db-tests.yml +++ b/.github/workflows/pull-db-tests.yml @@ -10,11 +10,15 @@ concurrency: jobs: files-changed: uses: ./.github/workflows/files-changed.yml + permissions: + contents: read test-pgsql: if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read services: pgsql: image: postgres:14 @@ -38,8 +42,8 @@ jobs: ports: - "9000:9000" steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true @@ -59,37 +63,39 @@ jobs: RACE_ENABLED: true TEST_TAGS: gogit TEST_LDAP: 1 - USE_REPO_TEST_DIR: 1 test-sqlite: if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true - run: make deps-backend - - run: make backend + - run: GOEXPERIMENT='' make backend env: TAGS: bindata gogit sqlite sqlite_unlock_notify - name: run migration tests run: make test-sqlite-migration - name: run tests - run: make test-sqlite + run: GOEXPERIMENT='' make test-sqlite timeout-minutes: 50 env: TAGS: bindata gogit sqlite sqlite_unlock_notify RACE_ENABLED: true TEST_TAGS: gogit sqlite sqlite_unlock_notify - USE_REPO_TEST_DIR: 1 test-unit: if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read services: elasticsearch: image: elasticsearch:7.5.0 @@ -124,8 +130,8 @@ jobs: ports: - 10000:10000 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true @@ -142,7 +148,7 @@ jobs: RACE_ENABLED: true GITHUB_READ_TOKEN: ${{ secrets.GITHUB_READ_TOKEN }} - name: unit-tests-gogit - run: make unit-test-coverage test-check + run: GOEXPERIMENT='' make unit-test-coverage test-check env: TAGS: bindata gogit RACE_ENABLED: true @@ -152,6 +158,8 @@ jobs: if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read services: mysql: # the bitnami mysql image has more options than the official one, it's easier to customize @@ -177,8 +185,8 @@ jobs: - "587:587" - "993:993" steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true @@ -196,13 +204,14 @@ jobs: env: TAGS: bindata RACE_ENABLED: true - USE_REPO_TEST_DIR: 1 TEST_INDEXER_CODE_ES_URL: "http://elastic:changeme@elasticsearch:9200" test-mssql: if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read services: mssql: image: mcr.microsoft.com/mssql/server:2019-latest @@ -217,8 +226,8 @@ jobs: ports: - 10000:10000 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true @@ -234,4 +243,3 @@ jobs: timeout-minutes: 50 env: TAGS: bindata - USE_REPO_TEST_DIR: 1 diff --git a/.github/workflows/pull-docker-dryrun.yml b/.github/workflows/pull-docker-dryrun.yml index f74277de671..bcc19e3eba8 100644 --- a/.github/workflows/pull-docker-dryrun.yml +++ b/.github/workflows/pull-docker-dryrun.yml @@ -10,26 +10,31 @@ concurrency: jobs: files-changed: uses: ./.github/workflows/files-changed.yml + permissions: + contents: read - regular: - if: needs.files-changed.outputs.docker == 'true' || needs.files-changed.outputs.actions == 'true' + container: + if: needs.files-changed.outputs.docker == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read steps: + - uses: actions/checkout@v6 + - uses: docker/setup-qemu-action@v3 - uses: docker/setup-buildx-action@v3 - - uses: docker/build-push-action@v5 + - name: Build regular container image + uses: docker/build-push-action@v6 with: + context: . + platforms: linux/amd64,linux/arm64,linux/riscv64 push: false - tags: gitea/gitea:linux-amd64 - - rootless: - if: needs.files-changed.outputs.docker == 'true' || needs.files-changed.outputs.actions == 'true' - needs: files-changed - runs-on: ubuntu-latest - steps: - - uses: docker/setup-buildx-action@v3 - - uses: docker/build-push-action@v5 + cache-from: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootful + - name: Build rootless container image + uses: docker/build-push-action@v6 with: + context: . push: false + platforms: linux/amd64,linux/arm64,linux/riscv64 file: Dockerfile.rootless - tags: gitea/gitea:linux-amd64 + cache-from: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootless diff --git a/.github/workflows/pull-e2e-tests.yml b/.github/workflows/pull-e2e-tests.yml index 89b32260ca0..c77f7af3f08 100644 --- a/.github/workflows/pull-e2e-tests.yml +++ b/.github/workflows/pull-e2e-tests.yml @@ -10,26 +10,34 @@ concurrency: jobs: files-changed: uses: ./.github/workflows/files-changed.yml + permissions: + contents: read test-e2e: - # the "test-e2e" won't pass, and it seems that there is no useful test, so skip - # if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.frontend == 'true' || needs.files-changed.outputs.actions == 'true' - if: false + if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.frontend == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v6 with: node-version: 24 - - run: make deps-frontend frontend deps-backend - - run: pnpm exec playwright install --with-deps - - run: make test-e2e-sqlite - timeout-minutes: 40 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + - run: make deps-frontend + - run: make frontend + - run: make deps-backend + - run: make gitea-e2e + - run: make playwright + - run: make test-e2e + timeout-minutes: 10 env: - USE_REPO_TEST_DIR: 1 + FORCE_COLOR: 1 + GITEA_TEST_E2E_DEBUG: 1 diff --git a/.github/workflows/pull-labeler.yml b/.github/workflows/pull-labeler.yml index 812819b5991..d05483e56ca 100644 --- a/.github/workflows/pull-labeler.yml +++ b/.github/workflows/pull-labeler.yml @@ -15,6 +15,6 @@ jobs: contents: read pull-requests: write steps: - - uses: actions/labeler@v5 + - uses: actions/labeler@v6 with: sync-labels: true diff --git a/.github/workflows/release-nightly.yml b/.github/workflows/release-nightly.yml index 3d652e4ad8b..a7b2fda0426 100644 --- a/.github/workflows/release-nightly.yml +++ b/.github/workflows/release-nightly.yml @@ -11,19 +11,23 @@ concurrency: jobs: nightly-binary: runs-on: namespace-profile-gitea-release-binary + permissions: + contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 # fetch all commits instead of only the last as some branches are long lived and could have many between versions # fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567 - run: git fetch --unshallow --quiet --tags --force - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v6 with: node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml - run: make deps-frontend deps-backend # xgo build - run: make release @@ -48,7 +52,7 @@ jobs: echo "Cleaned name is ${REF_NAME}" echo "branch=${REF_NAME}-nightly" >> "$GITHUB_OUTPUT" - name: configure aws - uses: aws-actions/configure-aws-credentials@v4 + uses: aws-actions/configure-aws-credentials@v5 with: aws-region: ${{ secrets.AWS_REGION }} aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} @@ -56,19 +60,17 @@ jobs: - name: upload binaries to s3 run: | aws s3 sync dist/release s3://${{ secrets.AWS_S3_BUCKET }}/gitea/${{ steps.clean_name.outputs.branch }} --no-progress - nightly-docker-rootful: + + nightly-container: runs-on: namespace-profile-gitea-release-docker permissions: + contents: read packages: write # to publish to ghcr.io steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 # fetch all commits instead of only the last as some branches are long lived and could have many between versions # fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567 - run: git fetch --unshallow --quiet --tags --force - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - check-latest: true - uses: docker/setup-qemu-action@v3 - uses: docker/setup-buildx-action@v3 - name: Get cleaned branch name @@ -76,6 +78,29 @@ jobs: run: | REF_NAME=$(echo "${{ github.ref }}" | sed -e 's/refs\/heads\///' -e 's/refs\/tags\///' -e 's/release\/v//') echo "branch=${REF_NAME}-nightly" >> "$GITHUB_OUTPUT" + - uses: docker/metadata-action@v5 + id: meta + with: + images: |- + gitea/gitea + ghcr.io/go-gitea/gitea + tags: | + type=raw,value=${{ steps.clean_name.outputs.branch }} + annotations: | + org.opencontainers.image.authors="maintainers@gitea.io" + - uses: docker/metadata-action@v5 + id: meta_rootless + with: + images: |- + gitea/gitea + ghcr.io/go-gitea/gitea + # each tag below will have the suffix of -rootless + flavor: | + suffix=-rootless + tags: | + type=raw,value=${{ steps.clean_name.outputs.branch }} + annotations: | + org.opencontainers.image.authors="maintainers@gitea.io" - name: Login to Docker Hub uses: docker/login-action@v3 with: @@ -87,57 +112,24 @@ jobs: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - name: fetch go modules - run: make vendor - - name: build rootful docker image - uses: docker/build-push-action@v5 + - name: build regular docker image + uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64,linux/arm64,linux/riscv64 push: true - 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 - # fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567 - - run: git fetch --unshallow --quiet --tags --force - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - check-latest: true - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 - - name: Get cleaned branch name - id: clean_name - run: | - REF_NAME=$(echo "${{ github.ref }}" | sed -e 's/refs\/heads\///' -e 's/refs\/tags\///' -e 's/release\/v//') - echo "branch=${REF_NAME}-nightly" >> "$GITHUB_OUTPUT" - - name: Login to Docker Hub - uses: docker/login-action@v3 - 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 + tags: ${{ steps.meta.outputs.tags }} + annotations: ${{ steps.meta.outputs.annotations }} + cache-from: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootful + cache-to: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootful,mode=max - name: build rootless docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . - platforms: linux/amd64,linux/arm64 + platforms: linux/amd64,linux/arm64,linux/riscv64 push: true file: Dockerfile.rootless - tags: |- - gitea/gitea:${{ steps.clean_name.outputs.branch }}-rootless - ghcr.io/go-gitea/gitea:${{ steps.clean_name.outputs.branch }}-rootless + tags: ${{ steps.meta_rootless.outputs.tags }} + annotations: ${{ steps.meta_rootless.outputs.annotations }} + cache-from: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootless + cache-to: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootless,mode=max diff --git a/.github/workflows/release-tag-rc.yml b/.github/workflows/release-tag-rc.yml index f4776a9ed8c..fab468c9b4b 100644 --- a/.github/workflows/release-tag-rc.yml +++ b/.github/workflows/release-tag-rc.yml @@ -12,19 +12,23 @@ concurrency: jobs: binary: runs-on: namespace-profile-gitea-release-binary + permissions: + contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 # fetch all commits instead of only the last as some branches are long lived and could have many between versions # fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567 - run: git fetch --unshallow --quiet --tags --force - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v6 with: node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml - run: make deps-frontend deps-backend # xgo build - run: make release @@ -49,7 +53,7 @@ jobs: echo "Cleaned name is ${REF_NAME}" echo "branch=${REF_NAME}" >> "$GITHUB_OUTPUT" - name: configure aws - uses: aws-actions/configure-aws-credentials@v4 + uses: aws-actions/configure-aws-credentials@v5 with: aws-region: ${{ secrets.AWS_REGION }} aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} @@ -58,7 +62,7 @@ jobs: run: | aws s3 sync dist/release s3://${{ secrets.AWS_S3_BUCKET }}/gitea/${{ steps.clean_name.outputs.branch }} --no-progress - name: Install GH CLI - uses: dev-hanz-ops/install-gh-cli-action@v0.1.0 + uses: dev-hanz-ops/install-gh-cli-action@v0.2.1 with: gh-cli-version: 2.39.1 - name: create github release @@ -66,12 +70,14 @@ jobs: gh release create ${{ github.ref_name }} --title ${{ github.ref_name }} --draft --notes-from-tag dist/release/* env: GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} - docker-rootful: + + container: runs-on: namespace-profile-gitea-release-docker permissions: + contents: read packages: write # to publish to ghcr.io steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 # fetch all commits instead of only the last as some branches are long lived and could have many between versions # fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567 - run: git fetch --unshallow --quiet --tags --force @@ -88,38 +94,10 @@ jobs: # 1.2.3-rc0 tags: | type=semver,pattern={{version}} - - name: Login to Docker Hub - uses: docker/login-action@v3 - 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: - context: . - platforms: linux/amd64,linux/arm64,linux/riscv64 - push: true - tags: ${{ steps.meta.outputs.tags }} - 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 - # fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567 - - run: git fetch --unshallow --quiet --tags --force - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 + annotations: | + org.opencontainers.image.authors="maintainers@gitea.io" - uses: docker/metadata-action@v5 - id: meta + id: meta_rootless with: images: |- gitea/gitea @@ -131,6 +109,8 @@ jobs: # 1.2.3-rc0 tags: | type=semver,pattern={{version}} + annotations: | + org.opencontainers.image.authors="maintainers@gitea.io" - name: Login to Docker Hub uses: docker/login-action@v3 with: @@ -142,12 +122,20 @@ jobs: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - name: build rootless docker image - uses: docker/build-push-action@v5 + - name: build regular container image + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64,linux/riscv64 + push: true + tags: ${{ steps.meta.outputs.tags }} + annotations: ${{ steps.meta.outputs.annotations }} + - name: build rootless container image + uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64,linux/arm64,linux/riscv64 push: true file: Dockerfile.rootless - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} + tags: ${{ steps.meta_rootless.outputs.tags }} + annotations: ${{ steps.meta_rootless.outputs.annotations }} diff --git a/.github/workflows/release-tag-version.yml b/.github/workflows/release-tag-version.yml index ad0820f31fe..113a33c3c7f 100644 --- a/.github/workflows/release-tag-version.yml +++ b/.github/workflows/release-tag-version.yml @@ -15,20 +15,23 @@ jobs: binary: runs-on: namespace-profile-gitea-release-binary permissions: + contents: read packages: write # to publish to ghcr.io steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 # fetch all commits instead of only the last as some branches are long lived and could have many between versions # fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567 - run: git fetch --unshallow --quiet --tags --force - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v6 with: node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml - run: make deps-frontend deps-backend # xgo build - run: make release @@ -53,7 +56,7 @@ jobs: echo "Cleaned name is ${REF_NAME}" echo "branch=${REF_NAME}" >> "$GITHUB_OUTPUT" - name: configure aws - uses: aws-actions/configure-aws-credentials@v4 + uses: aws-actions/configure-aws-credentials@v5 with: aws-region: ${{ secrets.AWS_REGION }} aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} @@ -62,7 +65,7 @@ jobs: run: | aws s3 sync dist/release s3://${{ secrets.AWS_S3_BUCKET }}/gitea/${{ steps.clean_name.outputs.branch }} --no-progress - name: Install GH CLI - uses: dev-hanz-ops/install-gh-cli-action@v0.1.0 + uses: dev-hanz-ops/install-gh-cli-action@v0.2.1 with: gh-cli-version: 2.39.1 - name: create github release @@ -70,12 +73,14 @@ jobs: gh release create ${{ github.ref_name }} --title ${{ github.ref_name }} --notes-from-tag dist/release/* env: GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} - docker-rootful: + + container: runs-on: namespace-profile-gitea-release-docker permissions: + contents: read packages: write # to publish to ghcr.io steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 # fetch all commits instead of only the last as some branches are long lived and could have many between versions # fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567 - run: git fetch --unshallow --quiet --tags --force @@ -96,36 +101,10 @@ jobs: type=semver,pattern={{version}} type=semver,pattern={{major}} type=semver,pattern={{major}}.{{minor}} - - name: Login to Docker Hub - uses: docker/login-action@v3 - 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: - context: . - platforms: linux/amd64,linux/arm64,linux/riscv64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - docker-rootless: - runs-on: namespace-profile-gitea-release-docker - 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 - # fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567 - - run: git fetch --unshallow --quiet --tags --force - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 + annotations: | + org.opencontainers.image.authors="maintainers@gitea.io" - uses: docker/metadata-action@v5 - id: meta + id: meta_rootless with: images: |- gitea/gitea @@ -142,6 +121,8 @@ jobs: type=semver,pattern={{version}} type=semver,pattern={{major}} type=semver,pattern={{major}}.{{minor}} + annotations: | + org.opencontainers.image.authors="maintainers@gitea.io" - name: Login to Docker Hub uses: docker/login-action@v3 with: @@ -153,12 +134,20 @@ jobs: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - name: build rootless docker image - uses: docker/build-push-action@v5 + - name: build regular container image + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64,linux/riscv64 + push: true + tags: ${{ steps.meta.outputs.tags }} + annotations: ${{ steps.meta.outputs.annotations }} + - name: build rootless container image + uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64,linux/arm64,linux/riscv64 push: true file: Dockerfile.rootless - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} + tags: ${{ steps.meta_rootless.outputs.tags }} + annotations: ${{ steps.meta_rootless.outputs.annotations }} diff --git a/.gitignore b/.gitignore index a580861a51d..45e8e9295fd 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,9 @@ __debug_bin* # Visual Studio /.vs/ +# mise version managment tool +mise.toml + *.cgo1.go *.cgo2.c _cgo_defun.c @@ -52,6 +55,7 @@ cpu.out *.log.*.gz /gitea +/gitea-e2e /gitea-vet /debug /integrations.test @@ -64,13 +68,9 @@ cpu.out /indexers /log /public/assets/img/avatar +/tests/e2e-output /tests/integration/gitea-integration-* /tests/integration/indexers-* -/tests/e2e/gitea-e2e-* -/tests/e2e/indexers-* -/tests/e2e/reports -/tests/e2e/test-artifacts -/tests/e2e/test-snapshots /tests/*.ini /tests/**/*.git/**/*.sample /node_modules @@ -86,7 +86,6 @@ cpu.out /vendor /VERSION /.air -/.go-licenses # Files and folders that were previously generated /public/assets/img/webpack @@ -118,7 +117,10 @@ prime/ /.goosehints /.windsurfrules /.github/copilot-instructions.md -/AGENT.md -/CLAUDE.md /llms.txt +# Ignore worktrees when working on multiple branches +.worktrees/ + +# A Makefile for custom make targets +Makefile.local diff --git a/.golangci.yml b/.golangci.yml index 2ad39fbae2c..afd91d65e59 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -6,15 +6,19 @@ linters: default: none enable: - bidichk + - bodyclose - depguard - dupl - errcheck - forbidigo + - gocheckcompilerdirectives - gocritic - govet - ineffassign - mirror + - modernize - nakedret + - nilnil - nolintlint - perfsprint - revive @@ -45,6 +49,8 @@ linters: desc: do not use the ini package, use gitea's config system instead - pkg: gitea.com/go-chi/cache desc: do not use the go-chi cache package, use gitea's cache system + - pkg: github.com/pkg/errors + desc: use builtin errors package instead nolintlint: allow-unused: false require-explanation: true @@ -55,38 +61,28 @@ linters: disabled-checks: - ifElseChain - singleCaseSwitch # Every time this occurred in the code, there was no other way. + - deprecatedComment # conflicts with go-swagger comments revive: severity: error rules: - - name: atomic - - name: bare-return - name: blank-imports - name: constant-logical-expr - name: context-as-argument - name: context-keys-type - name: dot-imports - - name: duplicated-imports - name: empty-lines - - name: error-naming - name: error-return - name: error-strings - - name: errorf - name: exported - name: identical-branches - name: if-return - name: increment-decrement - - name: indent-error-flow - name: modifies-value-receiver - name: package-comments - - name: range - - name: receiver-naming - name: redefines-builtin-id - - name: string-of-int - name: superfluous-else - name: time-naming - - name: unconditional-recursion - name: unexported-return - - name: unreachable-code - name: var-declaration - name: var-naming arguments: @@ -107,6 +103,12 @@ linters: - require-error usetesting: os-temp-dir: true + perfsprint: + concat-loop: false + govet: + enable: + - nilness + - unusedwrite exclusions: generated: lax presets: @@ -118,16 +120,12 @@ linters: - linters: - dupl - errcheck - - gocyclo - - gosec - staticcheck - unparam path: _test\.go - linters: - dupl - errcheck - - gocyclo - - gosec path: models/migrations/v - linters: - forbidigo @@ -139,12 +137,8 @@ linters: - gocritic text: (?i)`ID' should not be capitalized - linters: - - deadcode - unused text: (?i)swagger - - linters: - - staticcheck - text: (?i)argument x is overwritten before first use - linters: - gocritic text: '(?i)commentFormatting: put a space between `//` and comment text' @@ -153,6 +147,7 @@ linters: text: '(?i)exitAfterDefer:' paths: - node_modules + - .venv - public - web_src - third_party$ @@ -172,6 +167,7 @@ formatters: generated: lax paths: - node_modules + - .venv - public - web_src - third_party$ diff --git a/.yamllint.yaml b/.yamllint.yaml index 5a1e1e87515..1d33eceeb8d 100644 --- a/.yamllint.yaml +++ b/.yamllint.yaml @@ -21,9 +21,7 @@ rules: comments-indentation: level: error - document-start: - level: error - present: false + document-start: disable document-end: present: false diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000000..402a9d6945e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,10 @@ +# Instructions for agents + +- Use `make help` to find available development targets +- Before committing `.go` changes, run `make fmt` to format, and run `make lint-go` to lint +- Before committing `.ts` changes, run `make lint-js` to lint +- Before committing `go.mod` changes, run `make tidy` +- Before committing new `.go` files, add the current year into the copyright header +- Before committing any files, remove all trailing whitespace from source code lines +- Never force-push to pull request branches +- Always start issue and pull request comments with an authorship attribution diff --git a/CHANGELOG.md b/CHANGELOG.md index b72ac4849aa..b662cb4ad5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,500 @@ This changelog goes through the changes that have been made in each release without substantial changes to our git log; to see the highlights of what has been added to each release, please refer to the [blog](https://blog.gitea.com). -## [1.24.0](https://github.com/go-gitea/gitea/releases/tag/1.24.0) - 2025-05-26 +## [1.25.5](https://github.com/go-gitea/gitea/releases/tag/v1.25.5) - 2026-03-10 + +* SECURITY + * Toolchain Update to Go 1.25.6 (#36480) (#36487) + * Adjust the toolchain version (#36537) (#36542) + * Update toolchain to 1.25.8 for v1.25 (#36888) + * Prevent redirect bypasses via backslash-encoded paths (#36660) (#36716) + * Fix get release draft permission check (#36659) (#36715) + * Fix a bug user could change another user's primary email (#36586) (#36607) + * Fix OAuth2 authorization code expiry and reuse handling (#36797) (#36851) + * Add validation constraints for repository creation fields (#36671) (#36757) + * Fix bug to check whether user can update pull request branch or rebase branch (#36465) (#36838) + * Add migration http transport for push/sync mirror lfs (#36665) (#36691) + * Fix track time list permission check (#36662) (#36744) + * Fix track time issue id (#36664) (#36689) + * Fix path resolving (#36734) (#36746) + * Fix dump release asset bug (#36799) (#36839) + * Fix org permission API visibility checks for hidden members and private orgs (#36798) (#36841) + * Fix forwarded proto handling for public URL detection (#36810) (#36836) + * Add a git grep search timeout (#36809) (#36835) + * Fix oauth2 s256 (#36462) (#36477) +* ENHANCEMENTS + * Make `security-check` informational only (#36681) (#36852) + * Upgrade to github.com/cloudflare/circl 1.6.3, svgo 4.0.1, markdownlint-cli 0.48.0 (#36840) + * Add some validation on values provided to USER_DISABLED_FEATURES and EXTERNAL_USER_DISABLED_FEATURES (#36688) (#36692) + * Upgrade gogit to 5.16.5 (#36687) + * Add wrap to runner label list (#36565) (#36574) + * Add dnf5 command for Fedora in RPM package instructions (#36527) (#36572) + * Allow scroll propagation outside code editor (#36502) (#36510) +* BUGFIXES + * Fix non-admins unable to automerge PRs from forks (#36833) (#36843) + * Fix bug when pushing mirror with wiki (#36795) (#36807) + * Fix artifacts v4 backend upload problems (#36805) (#36834) + * Fix CRAN package version validation to allow more than 4 version components (#36813) (#36821) + * Fix force push time-line commit comments of pull request (#36653) (#36717) + * Fix SVG height calculation in diff viewer (#36748) (#36750) + * Fix push time bug (#36693) (#36713) + * Fix bug the protected branch rule name is conflicted with renamed branch name (#36650) (#36661) + * Fix bug when do LFS GC (#36500) (#36608) + * Fix focus lost bugs in the Monaco editor (#36609) + * Reprocess htmx content after loading more files (#36568) (#36577) + * Fix assignee sidebar links and empty placeholder (#36559) (#36563) + * Fix issues filter dropdown showing empty label scope section (#36535) (#36544) + * Fix various mermaid bugs (#36547) (#36552) + * Fix data race when uploading container blobs concurrently (#36524) (#36526) + * Correct spacing between username and bot label (#36473) (#36484) + +## [1.25.4](https://github.com/go-gitea/gitea/releases/tag/v1.25.4) - 2026-01-15 + +* SECURITY + * Release attachments must belong to the intended repo (#36347) (#36375) + * Fix permission check on org project operations (#36318) (#36373) + * Clean watches when make a repository private and check permission when send release emails (#36319) (#36370) + * Add more check for stopwatch read or list (#36340) (#36368) + * Fix openid setting check (#36346) (#36361) + * Fix cancel auto merge bug (#36341) (#36356) + * Fix delete attachment check (#36320) (#36355) + * LFS locks must belong to the intended repo (#36344) (#36349) + * Fix bug on notification read (#36339) #36387 +* ENHANCEMENTS + * Add more routes to the "expensive" list (#36290) + * Make "commit statuses" API accept slashes in "ref" (#36264) (#36275) +* BUGFIXES + * Fix git http service handling (#36396) + * Fix markdown newline handling during IME composition (#36421) (#36424) + * Fix missing repository id when migrating release attachments (#36389) + * Fix bug when compare in the pull request (#36363) (#36372) + * Fix incorrect text content detection (#36364) (#36369) + * Fill missing `has_code` in repository api (#36338) (#36359) + * Fix notifications pagination query parameters (#36351) (#36358) + * Fix some trivial problems (#36336) (#36337) + * Prevent panic when GitLab release has more links than sources (#36295) (#36305) + * Fix stats bug when syncing release (#36285) (#36294) + * Always honor user's choice for "delete branch after merge" (#36281) (#36286) + * Use the requested host for LFS links (#36242) (#36258) + * Fix panic when get editor config file (#36241) (#36247) + * Fix regression in writing authorized principals (#36213) (#36218) + * Fix WebAuthn error checking (#36219) (#36235) + +## [1.25.3](https://github.com/go-gitea/gitea/releases/tag/v1.25.3) - 2025-12-17 + +* SECURITY + * Bump toolchain to go1.25.5, misc fixes (#36082) +* ENHANCEMENTS + * Add strikethrough button to markdown editor (#36087) (#36104) + * Add "site admin" back to profile menu (#36010) (#36013) + * Improve math rendering (#36124) (#36125) +* BUGFIXES + * Check user visibility when redirecting to a renamed user (#36148) (#36159) + * Fix various bugs (#36139) (#36151) + * Fix bug when viewing the commit diff page with non-ANSI files (#36149) (#36150) + * Hide RSS icon when viewing a file not under a branch (#36135) (#36141) + * Fix SVG size calulation, only use `style` attribute (#36133) (#36134) + * Make Golang correctly delete temp files during uploading (#36128) (#36129) + * Fix the bug when ssh clone with redirect user or repository (#36039) (#36090) + * Use Golang net/smtp instead of gomail's smtp to send email (#36055) (#36083) + * Fix edit user email bug in API (#36068) (#36081) + * Fix bug when updating user email (#36058) (#36066) + * Fix incorrect viewed files counter if file has changed (#36009) (#36047) + * Fix container registry error handling (#36021) (#36037) + * Fix webAuthn insecure error view (#36165) (#36179) + * Fix some file icon ui (#36078) (#36088) + * Fix Actions `pull_request.paths` being triggered incorrectly by rebase (#36045) (#36054) + * Fix error handling in mailer and wiki services (#36041) (#36053) + * Fix bugs when comparing and creating pull request (#36166) (#36144) + +## [1.25.2](https://github.com/go-gitea/gitea/releases/tag/v1.25.2) - 2025-11-23 + +* SECURITY + * Upgrade golang.org/x/crypto to 0.45.0 (#35985) (#35988) + * Fix various permission & login related bugs (#36002) (#36004) +* ENHANCEMENTS + * Display source code downloads last for release attachments (#35897) (#35903) + * Change project default column icon to 'star' (#35967) (#35979) +* BUGFIXES + * Allow empty commit when merging pull request with squash style (#35989) (#36003) + * Fix container push tag overwriting (#35936) (#35954) + * Fix corrupted external render content (#35946) and upgrade golang.org/x packages (#35950) + * Limit reading bytes instead of ReadAll (#35928) (#35934) + * Use correct form field for allowed force push users in branch protection API (#35894) (#35908) + * Fix team member access check (#35899) (#35905) + * Fix conda null depend issue (#35900) (#35902) + * Set the dates to now when not specified by the caller (#35861) (#35874) + * Fix gogit ListEntriesRecursiveWithSize (#35862) + * Misc CSS fixes (#35888) (#35981) + * Don't show unnecessary error message to end users for DeleteBranchAfterMerge (#35937) (#35941) + * Load jQuery as early as possible to support custom scripts (#35926) (#35929) + * Allow to display embed images/pdfs when SERVE_DIRECT was enabled on MinIO storage (#35882) (#35917) + * Make OAuth2 issuer configurable (#35915) (#35916) + * Fix #35763: Add proper page title for project pages (#35773) (#35909) + * Fix avatar upload error handling (#35887) (#35890) + * Contribution heatmap improvements (#35876) (#35880) + * Remove padding override on `.ui .sha.label` (#35864) (#35873) + * Fix pull description code label background (#35865) (#35870) + +## [1.25.1](https://github.com/go-gitea/gitea/releases/tag/v1.25.1) - 2025-11-03 + +* BUGFIXES + * Make ACME email optional (#35849) #35857 + * Add a doctor command to fix inconsistent run status (#35840) (#35845) + * Remove wrong code (#35846) + * Fix viewed files number is not right if not all files loaded (#35821) (#35844) + * Fix incorrect pull request counter (#35819) (#35841) + * Upgrade go mail to 0.7.2 and fix the bug (#35833) (#35837) + * Revert gomail to v0.7.0 to fix sending mail failed (#35816) (#35824) + * Fix clone mixed bug (#35810) (#35822) + * Fix cli "Before" handling (#35797) (#35808) + * Improve and fix markup code preview rendering (#35777) (#35787) + * Fix actions rerun bug (#35783) (#35784) + * Fix actions schedule update issue (#35767) (#35774) + * Fix circular spin animation direction (#35785) (#35823) + * Fix file extension on gogs.png (#35793) (#35799) + * Add pnpm to Snapcraft (#35778) + +## [1.25.0](https://github.com/go-gitea/gitea/releases/tag/v1.25.0) - 2025-10-30 + +* BREAKING + * Return 201 Created for CreateVariable API responses (#34517) + * Add label 'state' to metric 'gitea_users' (#34326) +* SECURITY + * Upgrade security public key (#34956) + * Also include all security fixes in 1.24.x after 1.25.0-rc0 +* FEATURES + * Stream repo zip/tar.gz/bundle achives by default (#35487) + * Use configurable remote name for git commands (#35172) + * Send email on Workflow Run Success/Failure (#34982) + * Refactor OpenIDConnect to support SSH/FullName sync (#34978) + * Refactor repo contents API and add "contents-ext" API (#34822) + * Add support for 3D/CAD file formats preview (#34794) + * Improve instance wide ssh commit signing (#34341) + * Edit file workflow for creating a fork and proposing changes (#34240) + * Follow file symlinks in the UI to their target (#28835) + * Allow renaming/moving binary/LFS files in the UI (#34350) +* PERFORMANCE + * Improve the performance when detecting the file editable (#34653) +* ENHANCEMENTS + * Enable more markdown paste features in textarea editor (#35494) + * Don't store repo archives on `gitea dump` (#35467) + * Always return the relevant status information, even if no status exists. (#35335) + * Add start time on perf trace because it seems some steps haven't been recorded. (#35282) + * Remove deprecated auth sources (#35272) + * When sorting issues by nearest due date, issues without due date should be sorted ascending (#35267) + * Disable field count validation of CSV viewer (#35228) + * Add `has_code` to repository REST API (#35214) + * Display pull request in merged commit view (#35202) + * Support Basic Authentication for archive downloads (#35087) + * Add hover background to table rows in user and repo admin page (#35072) + * Partially refresh notifications list (#35010) + * Also display "recently pushed branch" alert on PR view (#35001) + * Refactor time tracker UI (#34983) + * Improve CLI commands (#34973) + * Improve project & label color picker and image scroll (#34971) + * Improve NuGet API Parity (#21291) (#34940) + * Support getting last commit message using contents-ext API (#34904) + * Adds title on branch commit counts (#34869) + * Add "Cancel workflow run" button to Actions list page (#34817) + * Improve img lazy loading (#34804) + * Forks repository list page follow other repositories page (#34784) + * Add ff_only parameter to POST /repos/{owner}/{repo}/merge-upstream (#34770) + * Rework delete org and rename org UI (#34762) + * Improve nuget/rubygems package registries (#34741) + * Add repo file tree item link behavior (#34730) + * Add issue delete notifier (#34592) + * Improve Actions list (#34530) + * Add a default tab on repo header when migrating (#34503) + * Add post-installation redirect based on admin account status (#34493) + * Trigger 'unlabeled' event when label is Deleted from PR (#34316) + * Support annotated tags when using create release API (#31840) + * Use lfs label for lfs file rather than a long description (#34363) + * Add "View workflow file" to Actions list page (#34538) + * Move organization's visibility change to danger zone. (#34814) + * Don't block site admin's operation if SECRET_KEY is lost (#35721) + * Make restricted users can access public repositories (#35693) + * The status icon of the Action step is consistent with GitHub (#35618) #35621 +* BUGFIXES + * Update tab title when navigating file tree (#35757) #35772 + * Fix "ref-issue" handling in markup (#35739) #35771 + * Fix webhook to prevent tag events from bypassing branch filters targets (#35567) #35577 + * Fix markup init after issue comment editing (#35536) #35537 + * Fix creating pull request failure when the target branch name is the same as some tag (#35552) #35582 + * Fix auto-expand and auto-scroll for actions logs (#35570) (#35583) #35586 + * Use inputs context when parsing workflows (#35590) #35595 + * Fix diffpatch API endpoint (#35610) #35613 + * Creating push comments before invoke pull request checking (#35647) #35668 + * Fix missing Close when error occurs and abused connection pool (#35658) #35670 + * Fix build (#35674) + * Use LFS object size instead of blob size when viewing a LFS file (#35679) + * Fix workflow run event status while rerunning a failed job (#35689) + * Avoid emoji mismatch and allow to only enable chosen emojis (#35692) + * Refactor legacy code, fix LFS auth bypass, fix symlink bypass (#35708) + * Fix various trivial problems (#35714) + * Fix attachment file size limit in server backend (#35519) + * Honor delete branch on merge repo setting when using merge API (#35488) + * Fix external render, make iframe render work (#35727, #35730) + * Upgrade go mail to 0.7.2 (#35748) + * Revert #18491, fix oauth2 client link account (#35745) + * Fix different behavior in status check pattern matching with double stars (#35474) + * Fix overflow in notifications list (#35446) + * Fix package link setting can only list limited repositories (#35394) + * Extend comment treepath length (#35389) + * Fix font-size in inline code comment preview (#35209) + * Move git config/remote to gitrepo package and add global lock to resolve possible conflict when updating repository git config file (#35151) + * Change some columns from text to longtext and fix column wrong type caused by xorm (#35141) + * Redirect to a presigned URL of HEAD for HEAD requests (#35088) + * Fix git commit committer parsing and add some tests (#35007) + * Fix OCI manifest parser (#34797) + * Refactor FindOrgOptions to use enum instead of bool, fix membership visibility (#34629) + * Fix notification count positioning for variable-width elements (#34597) + * Keeping consistent between UI and API about combined commit status state and fix some bugs (#34562) + * Fix possible panic (#34508) + * Fix autofocus behavior (#34397) + * Fix Actions API (#35204) + * Fix ListWorkflowRuns OpenAPI response model. (#35026) + * Small fix in Pull Requests page (#34612) + * Fix http auth header parsing (#34936) + * Fix modal + form abuse (#34921) + * Fix PR toggle WIP (#34920) + * Fix log fmt (#34810) + * Replace stopwatch toggle with explicit start/stop actions (#34818) + * Fix some package registry problems (#34759) + * Fix RPM package download routing & missing package version count (#34909) + * Fix repo search input height (#34330) + * Fix "The sidebar of the repository file list does not have a fixed height #34298" (#34321) + * Fix minor typos in two files #HSFDPMUW (#34944) + * Fix actions skipped commit status indicator (#34507) + * Fix job status aggregation logic (#35000) + * Fix broken OneDev migration caused by various REST API changes in OneDev 7.8.0 and later (#35216) + * Fix typo in oauth2_full_name_claim_name string (#35199) + * Fix typo in locale_en-US.ini (#35196) +* API + * Exposing TimeEstimate field in the API (#35475) + * UpdateBranch API supports renaming a branch (#35374) + * Add `owner` and `parent` fields clarification to docs (#35023) + * Improve OAuth2 provider (correct Issuer, respect ENABLED) (#34966) + * Add a `login`/`login-name`/`username` disambiguation to affected endpoint parameters and response/request models (#34901) + * Do not mutate incoming options to SearchRepositoryByName (#34553) + * Do not mutate incoming options to RenderUserSearch and SearchUsers (#34544) + * Export repo's manual merge settings (#34502) + * Add date range filtering to commit retrieval endpoints (#34497) + * Add endpoint deleting workflow run (#34337) + * Add workflow_run api + webhook (#33964) +* REFACTOR + * Move updateref and removeref to gitrepo and remove unnecessary open repository (#35511) + * Remove unused param `doer` (#34545) + * Split GetLatestCommitStatus as two functions (#34535) + * Use gitrepo.SetDefaultBranch when set default branch of wiki repository (#33911) + * Refactor editor (#34780) + * Refactor packages (#34777) + * Refactor container package (#34877) + * Refactor "change file" API (#34855) + * Rename pull request GetGitRefName to GetGitHeadRefName to prepare introducing GetGitMergeRefName (#35093) + * Move git command to git/gitcmd (#35483) + * Use db.WithTx/WithTx2 instead of TxContext when possible (#35428) + * Support Node.js 22.6 with type stripping (#35427) + * Migrate tools and configs to typescript, require node.js >= 22.18.0 (#35421) + * Check user and repo for redirects when using git via SSH transport (#35416) + * Remove the duplicated function GetTags (#35375) + * Refactor to use reflect.TypeFor (#35370) + * Deleting branch could delete broken branch which has database record but git branch is missing (#35360) + * Exit with success when already up to date (#35312) + * Split admin config settings templates to make it maintain easier (#35294) + * A small refactor to use context in the service layer (#35179) + * Refactor and update mail templates (#35150) + * Use db.WithTx/WithTx2 instead of TxContext when possible (#35130) + * Align `issue-title-buttons` with `list-header` (#35018) + * Add Notifications section in User Settings (#35008) + * Tweak placement of diff file menu (#34999) + * Refactor mail template and support preview (#34990) + * Rerun job only when run is done (#34970) + * Merge index.js (#34963) + * Refactor "delete-button" to "link-action" (#34962) + * Refactor webhook and fix feishu/lark secret (#34961) + * Exclude devtest.ts from tailwindcss (#34935) + * Refactor head navbar icons (#34922) + * Improve html escape (#34911) + * Improve tags list page (#34898) + * Improve `labels-list` rendering (#34846) + * Remove unused variable HUGO_VERSION (#34840) + * Correct migration tab name (#34826) + * Refactor template helper (#34819) + * Use `shallowRef` instead of `ref` in `.vue` files where possible (#34813) + * Use standalone function to update repository cols (#34811) + * Refactor wiki (#34805) + * Remove unnecessary duplicate code (#34733) + * Refactor embedded assets and drop unnecessary dependencies (#34692) + * Update x/crypto package and make builtin SSH use default parameters (#34667) + * Add `--color-logo`, matching the logo's primary color (#34639) + * Add openssh-keygen to rootless image (#34625) + * Replace update repository function in some places (#34566) + * Change "rejected" to "changes requested" in 3rd party PR review notification (#34481) + * Remove legacy template helper functions (#34426) + * Use run-name and evaluate workflow variables (#34301) + * Move HasWiki to repository service package (#33912) + * Move some functions from package git to gitrepo (#33910) +* TESTING + * Add webhook test for push event (#34442) + * Add a webhook push test for dev branch (#34421) + * Add migrations tests (#34456) (#34498) +* STYLE + * Enforce explanation for necessary nolints and fix bugs (#34883) + * Fix remaining issues after `gopls modernize` formatting (#34771) + * Update gofumpt, add go.mod ignore directive (#35434) + * Enforce nolint scope (#34851) + * Enable gocritic `equalFold` and fix issues (#34952) + * Run `gopls modernize` on codebase (#34751) + * Upgrade `gopls` to v0.19.0, add `make fix` (#34772) +* BUILD + * bump archives&rar dep (#35637) #35638 + * Use github.com/mholt/archives replace github.com/mholt/archiver (#35390) + * Update JS and PY dependencies (#35444) + * Upgrade devcontainer go version to 1.24.6 (#35298) + * Upgrade golang to 1.25.1 and add descriptions for the swagger structs' fields (#35418) + * Update JS and PY deps (#35191) + * Update JS and PY dependencies (#34391) + * Update go tool dependencies (#34845) + * Update `uint8-to-base64`, remove type stub (#34844) + * Switch to `@resvg/resvg-wasm` for `generate-images` (#35415) + * Switch to pnpm (#35274) + * Update chroma to v2.20.0 (#35220) + * Migrate to urfave v3 (#34510) + * Update JS deps, regenerate SVGs (#34640) + * Upgrade dependencies (#35384) + * Bump `@github/relative-time-element` to v4.4.8 (#34413) + * Update JS dependencies (#34951) + * Upgrade orgmode to v1.8.0 (#34721) + * Raise minimum Node.js version to 20, test on 24 (#34713) + * Update JS deps (#34701) + * Upgrade htmx to 2.0.6 (#34887) + * Update eslint to v9 (#35485) + * Update js dependencies (#35429) + * Clean up npm dependencies (#35508) + * Clean up npm dependencies (#35484) + * Bump setup-node to v5 (#35448) +* MISC + * Add gitignore rules to exclude LLM instruction files (#35076) + * Gitignore: Visual Studio settings folder (#34375) + * Improve language in en-US locale strings (#35124) + * Fixed all grammatical errors in locale_en-US.ini (#35053) + * Docs/fix typo and grammar in CONTRIBUTING.md (#35024) + * Improve english grammar and readability in locale_en-US.ini (#35017) + +## [1.24.7](https://github.com/go-gitea/gitea/releases/tag/v1.24.7) - 2025-10-24 + +* SECURITY + * Refactor legacy code (#35708) (#35713) + * Fixing issue #35530: Password Leak in Log Messages (#35584) (#35665) + * Fix a bug missed return (#35655) (#35671) +* BUGFIXES + * Fix inputing review comment will remove reviewer (#35591) (#35664) +* TESTING + * Mock external service in hcaptcha TestCaptcha (#35604) (#35663) + * Fix build (#35669) + +## [1.24.6](https://github.com/go-gitea/gitea/releases/tag/v1.24.6) - 2025-09-10 + +* SECURITY + * Upgrade xz to v0.5.15 (#35385) +* BUGFIXES + * Fix a compare page 404 bug when the pull request disabled (#35441) (#35453) + * Fix bug when issue disabled, pull request number in the commit message cannot be redirected (#35420) (#35442) + * Add author.name field to Swift Package Registry API response (#35410) (#35431) + * Remove usernames when empty in discord webhook (#35412) (#35417) + * Allow foreachref parser to grow its buffer (#35365) (#35376) + * Allow deleting comment with content via API like web did (#35346) (#35354) + * Fix atom/rss mixed error (#35345) (#35347) + * Fix review request webhook bug (#35339) + * Remove duplicate html IDs (#35210) (#35325) + * Fix LFS range size header response (#35277) (#35293) + * Fix GitHub release assets URL validation (#35287) (#35290) + * Fix token lifetime, closes #35230 (#35271) (#35281) + * Fix push commits comments when changing the pull request target branch (#35386) (#35443) + +## [1.24.5](https://github.com/go-gitea/gitea/releases/tag/v1.24.5) - 2025-08-12 + +* BUGFIXES + * Fix a bug where lfs gc never worked. (#35198) (#35255) + * Reload issue when sending webhook to make num comments is right. (#35243) (#35248) + * Fix bug when review pull request commits (#35192) (#35246) +* MISC + * Vertically center "Show Resolved" (#35211) (#35218) + +## [1.24.4](https://github.com/go-gitea/gitea/releases/tag/v1.24.4) - 2025-08-03 + +* BUGFIXES + * Fix various bugs (1.24) (#35186) + * Fix migrate input box bug (#35166) (#35171) + * Only hide dropzone when no files have been uploaded (#35156) (#35167) + * Fix review comment/dimiss comment x reference can be refereced back (#35094) (#35099) + * Fix submodule nil check (#35096) (#35098) +* MISC + * Don't use full-file highlight when there is a git diff textconv (#35114) (#35119) + * Increase gap on latest commit (#35104) (#35113) + +## [1.24.3](https://github.com/go-gitea/gitea/releases/tag/v1.24.3) - 2025-07-15 + +* BUGFIXES + * Fix form property assignment edge case (#35073) (#35078) + * Improve submodule relative path handling (#35056) (#35075) + * Fix incorrect comment diff hunk parsing, fix github asset ID nil panic (#35046) (#35055) + * Fix updating user visibility (#35036) (#35044) + * Support base64-encoded agit push options (#35037) (#35041) + * Make submodule link work with relative path (#35034) (#35038) + * Fix bug when displaying git user avatar in commits list (#35006) + * Fix API response for swagger spec (#35029) + * Start automerge check again after the conflict check and the schedule (#34988) (#35002) + * Fix the response format for actions/workflows (#35009) (#35016) + * Fix repo settings and protocol log problems (#35012) (#35013) + * Fix project images scroll (#34971) (#34972) + * Mark old reviews as stale on agit pr updates (#34933) (#34965) + * Fix git graph page (#34948) (#34949) + * Don't send trigger for a pending review's comment create/update/delete (#34928) (#34939) + * Fix some log and UI problems (#34863) (#34868) + * Fix archive API (#34853) (#34857) + * Ignore force pushes for changed files in a PR review (#34837) (#34843) + * Fix SSH LFS timeout (#34838) (#34842) + * Fix team permissions (#34827) (#34836) + * Fix job status aggregation logic (#34823) (#34835) + * Fix issue filter (#34914) (#34915) + * Fix typo in pull request merge warning message text (#34899) (#34903) + * Support the open-icon of folder (#34168) (#34896) + * Optimize flex layout of release attachment area (#34885) (#34886) + * Fix the issue of abnormal interface when there is no issue-item on the project page (#34791) (#34880) + * Skip updating timestamp when sync branch (#34875) + * Fix required contexts and commit status matching bug (#34815) (#34829) + +## [1.24.2](https://github.com/go-gitea/gitea/releases/tag/v1.24.2) - 2025-06-20 + +* BUGFIXES + * Fix container range bug (#34795) (#34796) + * Upgrade chi to v5.2.2 (#34798) (#34799) +* BUILD + * Bump poetry feature to new url for dev container (#34787) (#34790) + +## [1.24.1](https://github.com/go-gitea/gitea/releases/tag/v1.24.1) - 2025-06-18 + +* ENHANCEMENTS + * Improve alignment of commit status icon on commit page (#34750) (#34757) + * Support title and body query parameters for new PRs (#34537) (#34752) + +* BUGFIXES + * When using rules to delete packages, remove unclean bugs (#34632) (#34761) + * Fix ghost user in feeds when pushing in an actions, it should be gitea-actions (#34703) (#34756) + * Prevent double markdown link brackets when pasting URL (#34745) (#34748) + * Prevent duplicate form submissions when creating forks (#34714) (#34735) + * Fix markdown wrap (#34697) (#34702) + * Fix pull requests API convert panic when head repository is deleted. (#34685) (#34687) + * Fix commit message rendering and some UI problems (#34680) (#34683) + * Fix container range bug (#34725) (#34732) + * Fix incorrect cli default values (#34765) (#34766) + * Fix dropdown filter (#34708) (#34711) + * Hide href attribute of a tag if there is no target_url (#34556) (#34684) + * Fix tag target (#34781) #34783 + +## [1.24.0](https://github.com/go-gitea/gitea/releases/tag/v1.24.0) - 2025-05-26 * BREAKING * Make Gitea always use its internal config, ignore `/etc/gitconfig` (#33076) @@ -374,7 +867,7 @@ been added to each release, please refer to the [blog](https://blog.gitea.com). * Bump x/net (#32896) (#32900) * Only activity tab needs heatmap data loading (#34652) -## [1.23.8](https://github.com/go-gitea/gitea/releases/tag/1.23.8) - 2025-05-11 +## [1.23.8](https://github.com/go-gitea/gitea/releases/tag/v1.23.8) - 2025-05-11 * SECURITY * Fix a bug when uploading file via lfs ssh command (#34408) (#34411) @@ -401,7 +894,7 @@ been added to each release, please refer to the [blog](https://blog.gitea.com). * Bump go version in go.mod (#34160) * remove hardcoded 'code' string in clone_panel.tmpl (#34153) (#34158) -## [1.23.7](https://github.com/go-gitea/gitea/releases/tag/1.23.7) - 2025-04-07 +## [1.23.7](https://github.com/go-gitea/gitea/releases/tag/v1.23.7) - 2025-04-07 * Enhancements * Add a config option to block "expensive" pages for anonymous users (#34024) (#34071) @@ -499,7 +992,7 @@ been added to each release, please refer to the [blog](https://blog.gitea.com). * BUGFIXES * Fix a bug caused by status webhook template #33512 -## [1.23.2](https://github.com/go-gitea/gitea/releases/tag/1.23.2) - 2025-02-04 +## [1.23.2](https://github.com/go-gitea/gitea/releases/tag/v1.23.2) - 2025-02-04 * BREAKING * Add tests for webhook and fix some webhook bugs (#33396) (#33442) @@ -3029,7 +3522,7 @@ Key highlights of this release encompass significant changes categorized under ` * Improve decryption failure message (#24573) (#24575) * Makefile: Use portable !, not GNUish -not, with find(1). (#24565) (#24572) -## [1.19.3](https://github.com/go-gitea/gitea/releases/tag/1.19.3) - 2023-05-03 +## [1.19.3](https://github.com/go-gitea/gitea/releases/tag/v1.19.3) - 2023-05-03 * SECURITY * Use golang 1.20.4 to fix CVE-2023-24539, CVE-2023-24540, and CVE-2023-29400 @@ -3042,7 +3535,7 @@ Key highlights of this release encompass significant changes categorized under ` * Fix incorrect CurrentUser check for docker rootless (#24435) * Getting the tag list does not require being signed in (#24413) (#24416) -## [1.19.2](https://github.com/go-gitea/gitea/releases/tag/1.19.2) - 2023-04-26 +## [1.19.2](https://github.com/go-gitea/gitea/releases/tag/v1.19.2) - 2023-04-26 * SECURITY * Require repo scope for PATs for private repos and basic authentication (#24362) (#24364) @@ -3541,7 +4034,7 @@ Key highlights of this release encompass significant changes categorized under ` * Display attachments of review comment when comment content is blank (#23035) (#23046) * Return empty url for submodule tree entries (#23043) (#23048) -## [1.18.4](https://github.com/go-gitea/gitea/releases/tag/1.18.4) - 2023-02-20 +## [1.18.4](https://github.com/go-gitea/gitea/releases/tag/v1.18.4) - 2023-02-20 * SECURITY * Provide the ability to set password hash algorithm parameters (#22942) (#22943) @@ -3968,7 +4461,7 @@ Key highlights of this release encompass significant changes categorized under ` * Fix the mode of custom dir to 0700 in docker-rootless (#20861) (#20867) * Fix UI mis-align for PR commit history (#20845) (#20859) -## [1.17.1](https://github.com/go-gitea/gitea/releases/tag/1.17.1) - 2022-08-17 +## [1.17.1](https://github.com/go-gitea/gitea/releases/tag/v1.17.1) - 2022-08-17 * SECURITY * Correctly escape within tribute.js (#20831) (#20832) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000000..43c994c2d36 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 96e05c578fc..33b329182c7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,6 +4,7 @@ - [Contribution Guidelines](#contribution-guidelines) - [Introduction](#introduction) + - [AI Contribution Policy](#ai-contribution-policy) - [Issues](#issues) - [How to report issues](#how-to-report-issues) - [Types of issues](#types-of-issues) @@ -67,6 +68,19 @@ Sensitive security-related issues should be reported to [security@gitea.io](mail For configuring IDEs for Gitea development, see the [contributed IDE configurations](contrib/ide/). +## AI Contribution Policy + +Contributions made with the assistance of AI tools are welcome, but contributors must use them responsibly. + +1. Include related issues or pull requests in the prompt so that the AI has ideal context. +2. Review AI-generated code closely before submitting a pull request. +3. Manually test the changes and add appropriate automated tests where feasible. +4. Only use AI to assist in contributions that you understand well enough to respond to feedback without relying on AI. +5. Indicate AI-generated content in issue and pull requests descriptions and comments. Specify which model was used. +6. Do not use AI to reply to questions about your issue or pull request. The questions are for you, not an AI model. + +Maintainers reserve the right to close pull requests and issues that appear to be low-quality AI-generated content. We welcome new contributors, but cannot sustain the effort of supporting contributors who primarily defer to AI rather than engaging substantively with the review process. + ## Issues ### How to report issues @@ -80,7 +94,7 @@ The more detailed and specific you are, the faster we can fix the issue. \ It is really helpful if you can reproduce your problem on a site running on the latest commits, i.e. , as perhaps your problem has already been fixed on a current version. \ Please follow the guidelines described in [How to Report Bugs Effectively](http://www.chiark.greenend.org.uk/~sgtatham/bugs.html) for your report. -Please be kind, remember that Gitea comes at no cost to you, and you're getting free help. +Please be kind—remember that Gitea comes at no cost to you, and you're getting free help. ### Types of issues @@ -166,24 +180,31 @@ Here's how to run the test suite: - code lint -| | | -| :-------------------- | :---------------------------------------------------------------- | +| | | +| :-------------------- | :--------------------------------------------------------------------------- | |``make lint`` | lint everything (not needed if you only change the front- **or** backend) | -|``make lint-frontend`` | lint frontend files | -|``make lint-backend`` | lint backend files | +|``make lint-frontend`` | lint frontend files | +|``make lint-backend`` | lint backend files | - run tests (we suggest running them on Linux) -| Command | Action | | -| :------------------------------------- | :----------------------------------------------- | ------------ | -|``make test[\#SpecificTestName]`` | run unit test(s) | | -|``make test-sqlite[\#SpecificTestName]``| run [integration](tests/integration) test(s) for SQLite |[More details](tests/integration/README.md) | -|``make test-e2e-sqlite[\#SpecificTestName]``| run [end-to-end](tests/e2e) test(s) for SQLite |[More details](tests/e2e/README.md) | +| Command | Action | | +| :------------------------------------------ | :------------------------------------------------------- | ------------------------------------------- | +|``make test[\#SpecificTestName]`` | run unit test(s) | | +|``make test-sqlite[\#SpecificTestName]`` | run [integration](tests/integration) test(s) for SQLite | [More details](tests/integration/README.md) | +|``make test-e2e`` | run [end-to-end](tests/e2e) test(s) using Playwright | | + +- E2E test environment variables + +| Variable | Description | +| :------------------------ | :---------------------------------------------------------------- | +| ``GITEA_TEST_E2E_DEBUG`` | When set, show Gitea server output | +| ``GITEA_TEST_E2E_FLAGS`` | Additional flags passed to Playwright, for example ``--ui`` | ## Translation All translation work happens on [Crowdin](https://translate.gitea.com). -The only translation that is maintained in this repository is [the English translation](https://github.com/go-gitea/gitea/blob/main/options/locale/locale_en-US.ini). +The only translation that is maintained in this repository is [the English translation](https://github.com/go-gitea/gitea/blob/main/options/locale/locale_en-US.json). It is synced regularly with Crowdin. \ Other locales on main branch **should not** be updated manually as they will be overwritten with each sync. \ Once a language has reached a **satisfactory percentage** of translated keys (~25%), it will be synced back into this repo and included in the next released version. diff --git a/Dockerfile b/Dockerfile index 78a556497a6..9922cee9c41 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,15 @@ -# Build stage -FROM docker.io/library/golang:1.25-alpine3.22 AS build-env +# syntax=docker/dockerfile:1 +# Build frontend on the native platform to avoid QEMU-related issues with esbuild/webpack +FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.26-alpine3.23 AS frontend-build +RUN apk --no-cache add build-base git nodejs pnpm +WORKDIR /src +COPY package.json pnpm-lock.yaml .npmrc ./ +RUN --mount=type=cache,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile +COPY --exclude=.git/ . . +RUN make frontend -ARG GOPROXY -ENV GOPROXY=${GOPROXY:-direct} +# Build backend for each target platform +FROM docker.io/library/golang:1.26-alpine3.23 AS build-env ARG GITEA_VERSION ARG TAGS="sqlite sqlite_unlock_notify" @@ -12,37 +19,32 @@ ARG CGO_EXTRA_CFLAGS # Build deps RUN apk --no-cache add \ build-base \ - git \ - nodejs \ - npm \ - && npm install -g pnpm@10 \ - && rm -rf /var/cache/apk/* + git -# Setup repo -COPY . ${GOPATH}/src/code.gitea.io/gitea WORKDIR ${GOPATH}/src/code.gitea.io/gitea +COPY go.mod go.sum ./ +RUN go mod download +# Use COPY instead of bind mount as read-only one breaks makefile state tracking and read-write one needs binary to be moved as it's discarded. +# ".git" directory is mounted separately later only for version data extraction. +COPY --exclude=.git/ . . +COPY --from=frontend-build /src/public/assets public/assets -# Checkout version if set -RUN if [ -n "${GITEA_VERSION}" ]; then git checkout "${GITEA_VERSION}"; fi \ - && make clean-all build +# Build gitea, .git mount is required for version data +RUN --mount=type=cache,target="/root/.cache/go-build" \ + --mount=type=bind,source=".git/",target=".git/" \ + make backend -# Begin env-to-ini build -RUN go build contrib/environment-to-ini/environment-to-ini.go - -# Copy local files COPY docker/root /tmp/local -# Set permissions +# Set permissions for builds that made under windows which strips the executable bit from file RUN chmod 755 /tmp/local/usr/bin/entrypoint \ - /tmp/local/usr/local/bin/gitea \ + /tmp/local/usr/local/bin/* \ /tmp/local/etc/s6/gitea/* \ /tmp/local/etc/s6/openssh/* \ /tmp/local/etc/s6/.s6-svscan/* \ - /go/src/code.gitea.io/gitea/gitea \ - /go/src/code.gitea.io/gitea/environment-to-ini + /go/src/code.gitea.io/gitea/gitea -FROM docker.io/library/alpine:3.22 -LABEL maintainer="maintainers@gitea.io" +FROM docker.io/library/alpine:3.23 AS gitea EXPOSE 22 3000 @@ -57,8 +59,7 @@ RUN apk --no-cache add \ s6 \ sqlite \ su-exec \ - gnupg \ - && rm -rf /var/cache/apk/* + gnupg RUN addgroup \ -S -g 1000 \ @@ -72,14 +73,14 @@ RUN addgroup \ git && \ echo "git:*" | chpasswd -e +COPY --from=build-env /tmp/local / +COPY --from=build-env /go/src/code.gitea.io/gitea/gitea /app/gitea/gitea + ENV USER=git ENV GITEA_CUSTOM=/data/gitea VOLUME ["/data"] +# HINT: HEALTH-CHECK-ENDPOINT: don't use HEALTHCHECK, search this hint keyword for more information ENTRYPOINT ["/usr/bin/entrypoint"] CMD ["/usr/bin/s6-svscan", "/etc/s6"] - -COPY --from=build-env /tmp/local / -COPY --from=build-env /go/src/code.gitea.io/gitea/gitea /app/gitea/gitea -COPY --from=build-env /go/src/code.gitea.io/gitea/environment-to-ini /usr/local/bin/environment-to-ini diff --git a/Dockerfile.rootless b/Dockerfile.rootless index e83c1af33b9..a1742e3d51f 100644 --- a/Dockerfile.rootless +++ b/Dockerfile.rootless @@ -1,46 +1,45 @@ -# Build stage -FROM docker.io/library/golang:1.25-alpine3.22 AS build-env +# syntax=docker/dockerfile:1 +# Build frontend on the native platform to avoid QEMU-related issues with esbuild/webpack +FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.26-alpine3.23 AS frontend-build +RUN apk --no-cache add build-base git nodejs pnpm +WORKDIR /src +COPY package.json pnpm-lock.yaml .npmrc ./ +RUN --mount=type=cache,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile +COPY --exclude=.git/ . . +RUN make frontend -ARG GOPROXY -ENV GOPROXY=${GOPROXY:-direct} +# Build backend for each target platform +FROM docker.io/library/golang:1.26-alpine3.23 AS build-env ARG GITEA_VERSION ARG TAGS="sqlite sqlite_unlock_notify" ENV TAGS="bindata timetzdata $TAGS" ARG CGO_EXTRA_CFLAGS -#Build deps +# Build deps RUN apk --no-cache add \ build-base \ - git \ - nodejs \ - npm \ - && npm install -g pnpm@10 \ - && rm -rf /var/cache/apk/* + git -# Setup repo -COPY . ${GOPATH}/src/code.gitea.io/gitea WORKDIR ${GOPATH}/src/code.gitea.io/gitea +COPY go.mod go.sum ./ +RUN go mod download +# See the comments in Dockerfile +COPY --exclude=.git/ . . +COPY --from=frontend-build /src/public/assets public/assets -# Checkout version if set -RUN if [ -n "${GITEA_VERSION}" ]; then git checkout "${GITEA_VERSION}"; fi \ - && make clean-all build +# Build gitea, .git mount is required for version data +RUN --mount=type=cache,target="/root/.cache/go-build" \ + --mount=type=bind,source=".git/",target=".git/" \ + make backend -# Begin env-to-ini build -RUN go build contrib/environment-to-ini/environment-to-ini.go - -# Copy local files COPY docker/rootless /tmp/local -# Set permissions -RUN chmod 755 /tmp/local/usr/local/bin/docker-entrypoint.sh \ - /tmp/local/usr/local/bin/docker-setup.sh \ - /tmp/local/usr/local/bin/gitea \ - /go/src/code.gitea.io/gitea/gitea \ - /go/src/code.gitea.io/gitea/environment-to-ini +# Set permissions for builds that made under windows which strips the executable bit from file +RUN chmod 755 /tmp/local/usr/local/bin/* \ + /go/src/code.gitea.io/gitea/gitea -FROM docker.io/library/alpine:3.22 -LABEL maintainer="maintainers@gitea.io" +FROM docker.io/library/alpine:3.23 AS gitea-rootless EXPOSE 2222 3000 @@ -52,8 +51,7 @@ RUN apk --no-cache add \ git \ curl \ gnupg \ - openssh-keygen \ - && rm -rf /var/cache/apk/* + openssh-keygen RUN addgroup \ -S -g 1000 \ @@ -71,7 +69,6 @@ RUN chown git:git /var/lib/gitea /etc/gitea COPY --from=build-env /tmp/local / COPY --from=build-env --chown=root:root /go/src/code.gitea.io/gitea/gitea /app/gitea/gitea -COPY --from=build-env --chown=root:root /go/src/code.gitea.io/gitea/environment-to-ini /usr/local/bin/environment-to-ini # git:git USER 1000:1000 @@ -86,5 +83,6 @@ ENV HOME="/var/lib/gitea/git" VOLUME ["/var/lib/gitea", "/etc/gitea"] WORKDIR /var/lib/gitea +# HINT: HEALTH-CHECK-ENDPOINT: don't use HEALTHCHECK, search this hint keyword for more information ENTRYPOINT ["/usr/bin/dumb-init", "--", "/usr/local/bin/docker-entrypoint.sh"] CMD [] diff --git a/Makefile b/Makefile index 637e64210ac..4d1bd96ea51 100644 --- a/Makefile +++ b/Makefile @@ -1,22 +1,9 @@ -ifeq ($(USE_REPO_TEST_DIR),1) - -# This rule replaces the whole Makefile when we're trying to use /tmp repository temporary files -location = $(CURDIR)/$(word $(words $(MAKEFILE_LIST)),$(MAKEFILE_LIST)) -self := $(location) - -%: - @tmpdir=`mktemp --tmpdir -d` ; \ - echo Using temporary directory $$tmpdir for test repositories ; \ - USE_REPO_TEST_DIR= $(MAKE) -f $(self) --no-print-directory REPO_TEST_DIR=$$tmpdir/ $@ ; \ - STATUS=$$? ; rm -r "$$tmpdir" ; exit $$STATUS - -else - -# This is the "normal" part of the Makefile - DIST := dist DIST_DIRS := $(DIST)/binaries $(DIST)/release -IMPORT := code.gitea.io/gitea + +# By default use go's 1.25 experimental json v2 library when building +# TODO: remove when no longer experimental +export GOEXPERIMENT ?= jsonv2 GO ?= go SHASUM ?= shasum -a 256 @@ -27,17 +14,14 @@ XGO_VERSION := go-1.25.x AIR_PACKAGE ?= github.com/air-verse/air@v1 EDITORCONFIG_CHECKER_PACKAGE ?= github.com/editorconfig-checker/editorconfig-checker/v3/cmd/editorconfig-checker@v3 -GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.9.1 -GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.4.0 +GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.9.2 +GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.2 GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.15 -MISSPELL_PACKAGE ?= github.com/golangci/misspell/cmd/misspell@v0.7.0 -SWAGGER_PACKAGE ?= github.com/go-swagger/go-swagger/cmd/swagger@717e3cb29becaaf00e56953556c6d80f8a01b286 +MISSPELL_PACKAGE ?= github.com/golangci/misspell/cmd/misspell@v0.8.0 +SWAGGER_PACKAGE ?= github.com/go-swagger/go-swagger/cmd/swagger@v0.33.1 XGO_PACKAGE ?= src.techknowlogick.com/xgo@latest -GO_LICENSES_PACKAGE ?= github.com/google/go-licenses@v1 GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1 -ACTIONLINT_PACKAGE ?= github.com/rhysd/actionlint/cmd/actionlint@v1 -GOPLS_PACKAGE ?= golang.org/x/tools/gopls@v0.20.0 -GOPLS_MODERNIZE_PACKAGE ?= golang.org/x/tools/gopls/internal/analysis/modernize/cmd/modernize@v0.20.0 +ACTIONLINT_PACKAGE ?= github.com/rhysd/actionlint/cmd/actionlint@v1.7.11 DOCKER_IMAGE ?= gitea/gitea DOCKER_TAG ?= latest @@ -69,9 +53,11 @@ endif ifeq ($(IS_WINDOWS),yes) GOFLAGS := -v -buildmode=exe EXECUTABLE ?= gitea.exe + EXECUTABLE_E2E ?= gitea-e2e.exe else GOFLAGS := -v EXECUTABLE ?= gitea + EXECUTABLE_E2E ?= gitea-e2e endif ifeq ($(shell sed --version 2>/dev/null | grep -q GNU && echo gnu),gnu) @@ -82,7 +68,6 @@ endif EXTRA_GOFLAGS ?= -MAKE_VERSION := $(shell "$(MAKE)" -v | cat | head -n 1) MAKE_EVIDENCE_DIR := .make_evidence GOTESTFLAGS ?= @@ -98,7 +83,7 @@ GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) # Enable typescript support in Node.js before 22.18 # TODO: Remove this once we can raise the minimum Node.js version to 22.18 (alpine >= 3.23) -NODE_VERSION := $(shell printf "%03d%03d%03d" $(shell node -v 2>/dev/null | cut -c2- | tr '.' ' ')) +NODE_VERSION := $(shell printf "%03d%03d%03d" $(shell node -v 2>/dev/null | cut -c2- | sed 's/-.*//' | tr '.' ' ')) ifeq ($(shell test "$(NODE_VERSION)" -lt "022018000"; echo $$?),0) NODE_VARS := NODE_OPTIONS="--experimental-strip-types" else @@ -128,11 +113,11 @@ ifeq ($(VERSION),main) VERSION := main-nightly endif -LDFLAGS := $(LDFLAGS) -X "main.MakeVersion=$(MAKE_VERSION)" -X "main.Version=$(GITEA_VERSION)" -X "main.Tags=$(TAGS)" +LDFLAGS := $(LDFLAGS) -X "main.Version=$(GITEA_VERSION)" -X "main.Tags=$(TAGS)" LINUX_ARCHS ?= linux/amd64,linux/386,linux/arm-5,linux/arm-6,linux/arm64,linux/riscv64 -GO_TEST_PACKAGES ?= $(filter-out $(shell $(GO) list code.gitea.io/gitea/models/migrations/...) code.gitea.io/gitea/tests/integration/migration-test code.gitea.io/gitea/tests code.gitea.io/gitea/tests/integration code.gitea.io/gitea/tests/e2e,$(shell $(GO) list ./... | grep -v /vendor/)) +GO_TEST_PACKAGES ?= $(filter-out $(shell $(GO) list code.gitea.io/gitea/models/migrations/...) code.gitea.io/gitea/tests/integration/migration-test code.gitea.io/gitea/tests code.gitea.io/gitea/tests/integration,$(shell $(GO) list ./... | grep -v /vendor/)) MIGRATE_TEST_PACKAGES ?= $(shell $(GO) list code.gitea.io/gitea/models/migrations/...) WEBPACK_SOURCES := $(shell find web_src/js web_src/css -type f) @@ -148,7 +133,6 @@ SVG_DEST_DIR := public/assets/img/svg AIR_TMP_DIR := .air -GO_LICENSE_TMP_DIR := .go-licenses GO_LICENSE_FILE := assets/go-licenses.json TAGS ?= @@ -157,24 +141,21 @@ TAGS_EVIDENCE := $(MAKE_EVIDENCE_DIR)/tags TEST_TAGS ?= $(TAGS_SPLIT) sqlite sqlite_unlock_notify -TAR_EXCLUDES := .git data indexers queues log node_modules $(EXECUTABLE) $(DIST) $(MAKE_EVIDENCE_DIR) $(AIR_TMP_DIR) $(GO_LICENSE_TMP_DIR) +TAR_EXCLUDES := .git data indexers queues log node_modules $(EXECUTABLE) $(DIST) $(MAKE_EVIDENCE_DIR) $(AIR_TMP_DIR) -GO_DIRS := build cmd models modules routers services tests +GO_DIRS := build cmd models modules routers services tests tools WEB_DIRS := web_src/js web_src/css ESLINT_FILES := web_src/js tools *.ts tests/e2e STYLELINT_FILES := web_src/css web_src/js/components/*.vue -SPELLCHECK_FILES := $(GO_DIRS) $(WEB_DIRS) templates options/locale/locale_en-US.ini .github $(filter-out CHANGELOG.md, $(wildcard *.go *.md *.yml *.yaml *.toml)) $(filter-out tools/misspellings.csv, $(wildcard tools/*)) -EDITORCONFIG_FILES := templates .github/workflows options/locale/locale_en-US.ini +SPELLCHECK_FILES := $(GO_DIRS) $(WEB_DIRS) templates options/locale/locale_en-US.json .github $(filter-out CHANGELOG.md, $(wildcard *.go *.md *.yml *.yaml *.toml)) +EDITORCONFIG_FILES := templates .github/workflows options/locale/locale_en-US.json GO_SOURCES := $(wildcard *.go) GO_SOURCES += $(shell find $(GO_DIRS) -type f -name "*.go") GO_SOURCES += $(GENERATED_GO_DEST) -# Force installation of playwright dependencies by setting this flag -ifdef DEPS_PLAYWRIGHT - PLAYWRIGHT_FLAGS += --with-deps -endif +ESLINT_CONCURRENCY ?= 2 SWAGGER_SPEC := templates/swagger/v1_json.tmpl SWAGGER_SPEC_INPUT := templates/swagger/v1_input.json @@ -195,26 +176,20 @@ TEST_MSSQL_DBNAME ?= gitea TEST_MSSQL_USERNAME ?= sa TEST_MSSQL_PASSWORD ?= MwantsaSecurePassword1 +# Include local Makefile +# Makefile.local is listed in .gitignore +sinclude Makefile.local + .PHONY: all all: build .PHONY: help help: Makefile ## print Makefile help information. @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m[TARGETS] default target: build\033[0m\n\n\033[35mTargets:\033[0m\n"} /^[0-9A-Za-z._-]+:.*?##/ { printf " \033[36m%-45s\033[0m %s\n", $$1, $$2 }' Makefile #$(MAKEFILE_LIST) - @printf " \033[36m%-46s\033[0m %s\n" "test-e2e[#TestSpecificName]" "test end to end using playwright" + @printf " \033[36m%-46s\033[0m %s\n" "test-e2e" "test end to end using playwright" @printf " \033[36m%-46s\033[0m %s\n" "test[#TestSpecificName]" "run unit test" @printf " \033[36m%-46s\033[0m %s\n" "test-sqlite[#TestSpecificName]" "run integration test for sqlite" -.PHONY: go-check -go-check: - $(eval MIN_GO_VERSION_STR := $(shell grep -Eo '^go\s+[0-9]+\.[0-9]+' go.mod | cut -d' ' -f2)) - $(eval MIN_GO_VERSION := $(shell printf "%03d%03d" $(shell echo '$(MIN_GO_VERSION_STR)' | tr '.' ' '))) - $(eval GO_VERSION := $(shell printf "%03d%03d" $(shell $(GO) version | grep -Eo '[0-9]+\.[0-9]+' | tr '.' ' ');)) - @if [ "$(GO_VERSION)" -lt "$(MIN_GO_VERSION)" ]; then \ - echo "Gitea requires Go $(MIN_GO_VERSION_STR) or greater to build. You can get it at https://go.dev/dl/"; \ - exit 1; \ - fi - .PHONY: git-check git-check: @if git lfs >/dev/null 2>&1 ; then : ; else \ @@ -222,39 +197,24 @@ git-check: exit 1; \ fi -.PHONY: node-check -node-check: - $(eval MIN_NODE_VERSION_STR := $(shell grep -Eo '"node":.*[0-9.]+"' package.json | sed -n 's/.*[^0-9.]\([0-9.]*\)"/\1/p')) - $(eval MIN_NODE_VERSION := $(shell printf "%03d%03d%03d" $(shell echo '$(MIN_NODE_VERSION_STR)' | tr '.' ' '))) - $(eval PNPM_MISSING := $(shell hash pnpm > /dev/null 2>&1 || echo 1)) - @if [ "$(NODE_VERSION)" -lt "$(MIN_NODE_VERSION)" ]; then \ - echo "Gitea requires Node.js $(MIN_NODE_VERSION_STR) or greater to build. You can get it at https://nodejs.org/en/download/"; \ - exit 1; \ - fi - @if [ "$(PNPM_MISSING)" = "1" ]; then \ - echo "Gitea requires pnpm to build. You can install it at https://pnpm.io/installation"; \ - exit 1; \ - fi - .PHONY: clean-all clean-all: clean ## delete backend, frontend and integration files rm -rf $(WEBPACK_DEST_ENTRIES) node_modules .PHONY: clean clean: ## delete backend and integration files - rm -rf $(EXECUTABLE) $(DIST) $(BINDATA_DEST_WILDCARD) \ + rm -rf $(EXECUTABLE) $(EXECUTABLE_E2E) $(DIST) $(BINDATA_DEST_WILDCARD) \ integrations*.test \ - e2e*.test \ tests/integration/gitea-integration-* \ tests/integration/indexers-* \ - tests/mysql.ini tests/pgsql.ini tests/mssql.ini man/ \ + tests/sqlite.ini tests/mysql.ini tests/pgsql.ini tests/mssql.ini man/ \ tests/e2e/gitea-e2e-*/ \ tests/e2e/indexers-*/ \ tests/e2e/reports/ tests/e2e/test-artifacts/ tests/e2e/test-snapshots/ .PHONY: fmt fmt: ## format the Go and template code - @GOFUMPT_PACKAGE=$(GOFUMPT_PACKAGE) $(GO) run build/code-batch-process.go gitea-fmt -w '{file-list}' + @GOFUMPT_PACKAGE=$(GOFUMPT_PACKAGE) $(GO) run tools/code-batch-process.go gitea-fmt -w '{file-list}' $(eval TEMPLATES := $(shell find templates -type f -name '*.tmpl')) @# strip whitespace after '{{' or '(' and before '}}' or ')' unless there is only @# whitespace before it @@ -272,19 +232,6 @@ fmt-check: fmt exit 1; \ fi -.PHONY: fix -fix: ## apply automated fixes to Go code - $(GO) run $(GOPLS_MODERNIZE_PACKAGE) -fix ./... - -.PHONY: fix-check -fix-check: fix - @diff=$$(git diff --color=always $(GO_SOURCES)); \ - if [ -n "$$diff" ]; then \ - echo "Please run 'make fix' and commit the result:"; \ - printf "%s" "$${diff}"; \ - exit 1; \ - fi - .PHONY: $(TAGS_EVIDENCE) $(TAGS_EVIDENCE): @mkdir -p $(MAKE_EVIDENCE_DIR) @@ -324,7 +271,7 @@ checks: checks-frontend checks-backend ## run various consistency checks checks-frontend: lockfile-check svg-check ## check frontend files .PHONY: checks-backend -checks-backend: tidy-check swagger-check fmt-check fix-check swagger-validate security-check ## check backend files +checks-backend: tidy-check swagger-check fmt-check swagger-validate security-check ## check backend files .PHONY: lint lint: lint-frontend lint-backend lint-spell ## lint everything @@ -339,19 +286,19 @@ lint-frontend: lint-js lint-css ## lint frontend files lint-frontend-fix: lint-js-fix lint-css-fix ## lint frontend files and fix issues .PHONY: lint-backend -lint-backend: lint-go lint-go-gitea-vet lint-go-gopls lint-editorconfig ## lint backend files +lint-backend: lint-go lint-go-gitea-vet lint-editorconfig ## lint backend files .PHONY: lint-backend-fix lint-backend-fix: lint-go-fix lint-go-gitea-vet lint-editorconfig ## lint backend files and fix issues .PHONY: lint-js -lint-js: node_modules ## lint js files - $(NODE_VARS) pnpm exec eslint --color --max-warnings=0 --flag unstable_native_nodejs_ts_config $(ESLINT_FILES) +lint-js: node_modules ## lint js and ts files + $(NODE_VARS) pnpm exec eslint --color --max-warnings=0 --concurrency $(ESLINT_CONCURRENCY) $(ESLINT_FILES) $(NODE_VARS) pnpm exec vue-tsc .PHONY: lint-js-fix -lint-js-fix: node_modules ## lint js files and fix issues - $(NODE_VARS) pnpm exec eslint --color --max-warnings=0 --flag unstable_native_nodejs_ts_config $(ESLINT_FILES) --fix +lint-js-fix: node_modules ## lint js and ts files and fix issues + $(NODE_VARS) pnpm exec eslint --color --max-warnings=0 --concurrency $(ESLINT_CONCURRENCY) $(ESLINT_FILES) --fix $(NODE_VARS) pnpm exec vue-tsc .PHONY: lint-css @@ -370,13 +317,17 @@ lint-swagger: node_modules ## lint swagger files lint-md: node_modules ## lint markdown files $(NODE_VARS) pnpm exec markdownlint *.md +.PHONY: lint-md-fix +lint-md-fix: node_modules ## lint markdown files and fix issues + $(NODE_VARS) pnpm exec markdownlint --fix *.md + .PHONY: lint-spell lint-spell: ## lint spelling - @go run $(MISSPELL_PACKAGE) -dict tools/misspellings.csv -error $(SPELLCHECK_FILES) + @git ls-files $(SPELLCHECK_FILES) | xargs go run $(MISSPELL_PACKAGE) -dict assets/misspellings.csv -error .PHONY: lint-spell-fix lint-spell-fix: ## lint spelling and fix issues - @go run $(MISSPELL_PACKAGE) -dict tools/misspellings.csv -w $(SPELLCHECK_FILES) + @git ls-files $(SPELLCHECK_FILES) | xargs go run $(MISSPELL_PACKAGE) -dict assets/misspellings.csv -w .PHONY: lint-go lint-go: ## lint go files @@ -396,13 +347,7 @@ lint-go-windows: .PHONY: lint-go-gitea-vet lint-go-gitea-vet: ## lint go files with gitea-vet @echo "Running gitea-vet..." - @GOOS= GOARCH= $(GO) build code.gitea.io/gitea-vet - @$(GO) vet -vettool=gitea-vet ./... - -.PHONY: lint-go-gopls -lint-go-gopls: ## lint go files with gopls - @echo "Running gopls check..." - @GO=$(GO) GOPLS_PACKAGE=$(GOPLS_PACKAGE) tools/lint-go-gopls.sh $(GO_SOURCES) + @$(GO) vet -vettool="$(shell GOOS= GOARCH= go tool -n gitea-vet)" ./... .PHONY: lint-editorconfig lint-editorconfig: @@ -422,17 +367,25 @@ lint-templates: .venv node_modules ## lint template files lint-yaml: .venv ## lint yaml files @uv run --frozen yamllint -s . +.PHONY: lint-json +lint-json: node_modules ## lint json files + $(NODE_VARS) pnpm exec eslint -c eslint.json.config.ts --color --max-warnings=0 --concurrency $(ESLINT_CONCURRENCY) + +.PHONY: lint-json-fix +lint-json-fix: node_modules ## lint and fix json files + $(NODE_VARS) pnpm exec eslint -c eslint.json.config.ts --color --max-warnings=0 --concurrency $(ESLINT_CONCURRENCY) --fix + .PHONY: watch watch: ## watch everything and continuously rebuild @bash tools/watch.sh .PHONY: watch-frontend -watch-frontend: node-check node_modules ## watch frontend files and continuously rebuild +watch-frontend: node_modules ## watch frontend files and continuously rebuild @rm -rf $(WEBPACK_DEST_ENTRIES) NODE_ENV=development $(NODE_VARS) pnpm exec webpack --watch --progress --disable-interpret .PHONY: watch-backend -watch-backend: go-check ## watch backend files and continuously rebuild +watch-backend: ## watch backend files and continuously rebuild GITEA_RUN_MODE=dev $(GO) run $(AIR_PACKAGE) -c .air.toml .PHONY: test @@ -468,7 +421,7 @@ test\#%: coverage: grep '^\(mode: .*\)\|\(.*:[0-9]\+\.[0-9]\+,[0-9]\+\.[0-9]\+ [0-9]\+ [0-9]\+\)$$' coverage.out > coverage-bodged.out grep '^\(mode: .*\)\|\(.*:[0-9]\+\.[0-9]\+,[0-9]\+\.[0-9]\+ [0-9]\+ [0-9]\+\)$$' integration.coverage.out > integration.coverage-bodged.out - $(GO) run build/gocovmerge.go integration.coverage-bodged.out coverage-bodged.out > coverage.all + $(GO) run tools/gocovmerge.go integration.coverage-bodged.out coverage-bodged.out > coverage.all .PHONY: unit-test-coverage unit-test-coverage: @@ -498,25 +451,20 @@ tidy-check: tidy go-licenses: $(GO_LICENSE_FILE) ## regenerate go licenses $(GO_LICENSE_FILE): go.mod go.sum - @rm -rf $(GO_LICENSE_FILE) - $(GO) install $(GO_LICENSES_PACKAGE) - -GOOS=linux CGO_ENABLED=1 go-licenses save . --force --save_path=$(GO_LICENSE_TMP_DIR) 2>/dev/null - $(GO) run build/generate-go-licenses.go $(GO_LICENSE_TMP_DIR) $(GO_LICENSE_FILE) - @rm -rf $(GO_LICENSE_TMP_DIR) + GO=$(GO) $(GO) run build/generate-go-licenses.go $(GO_LICENSE_FILE) generate-ini-sqlite: - sed -e 's|{{REPO_TEST_DIR}}|${REPO_TEST_DIR}|g' \ + sed -e 's|{{WORK_PATH}}|$(CURDIR)/tests/$(or $(TEST_TYPE),integration)/gitea-$(or $(TEST_TYPE),integration)-sqlite|g' \ -e 's|{{TEST_LOGGER}}|$(or $(TEST_LOGGER),test$(COMMA)file)|g' \ - -e 's|{{TEST_TYPE}}|$(or $(TEST_TYPE),integration)|g' \ tests/sqlite.ini.tmpl > tests/sqlite.ini .PHONY: test-sqlite test-sqlite: integrations.sqlite.test generate-ini-sqlite - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/sqlite.ini ./integrations.sqlite.test + GITEA_TEST_CONF=tests/sqlite.ini ./integrations.sqlite.test .PHONY: test-sqlite\#% test-sqlite\#%: integrations.sqlite.test generate-ini-sqlite - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/sqlite.ini ./integrations.sqlite.test -test.run $(subst .,/,$*) + GITEA_TEST_CONF=tests/sqlite.ini ./integrations.sqlite.test -test.run $(subst .,/,$*) .PHONY: test-sqlite-migration test-sqlite-migration: migrations.sqlite.test migrations.individual.sqlite.test @@ -526,18 +474,17 @@ generate-ini-mysql: -e 's|{{TEST_MYSQL_DBNAME}}|${TEST_MYSQL_DBNAME}|g' \ -e 's|{{TEST_MYSQL_USERNAME}}|${TEST_MYSQL_USERNAME}|g' \ -e 's|{{TEST_MYSQL_PASSWORD}}|${TEST_MYSQL_PASSWORD}|g' \ - -e 's|{{REPO_TEST_DIR}}|${REPO_TEST_DIR}|g' \ + -e 's|{{WORK_PATH}}|$(CURDIR)/tests/$(or $(TEST_TYPE),integration)/gitea-$(or $(TEST_TYPE),integration)-mysql|g' \ -e 's|{{TEST_LOGGER}}|$(or $(TEST_LOGGER),test$(COMMA)file)|g' \ - -e 's|{{TEST_TYPE}}|$(or $(TEST_TYPE),integration)|g' \ tests/mysql.ini.tmpl > tests/mysql.ini .PHONY: test-mysql test-mysql: integrations.mysql.test generate-ini-mysql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mysql.ini ./integrations.mysql.test + GITEA_TEST_CONF=tests/mysql.ini ./integrations.mysql.test .PHONY: test-mysql\#% test-mysql\#%: integrations.mysql.test generate-ini-mysql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mysql.ini ./integrations.mysql.test -test.run $(subst .,/,$*) + GITEA_TEST_CONF=tests/mysql.ini ./integrations.mysql.test -test.run $(subst .,/,$*) .PHONY: test-mysql-migration test-mysql-migration: migrations.mysql.test migrations.individual.mysql.test @@ -549,18 +496,17 @@ generate-ini-pgsql: -e 's|{{TEST_PGSQL_PASSWORD}}|${TEST_PGSQL_PASSWORD}|g' \ -e 's|{{TEST_PGSQL_SCHEMA}}|${TEST_PGSQL_SCHEMA}|g' \ -e 's|{{TEST_MINIO_ENDPOINT}}|${TEST_MINIO_ENDPOINT}|g' \ - -e 's|{{REPO_TEST_DIR}}|${REPO_TEST_DIR}|g' \ + -e 's|{{WORK_PATH}}|$(CURDIR)/tests/$(or $(TEST_TYPE),integration)/gitea-$(or $(TEST_TYPE),integration)-pgsql|g' \ -e 's|{{TEST_LOGGER}}|$(or $(TEST_LOGGER),test$(COMMA)file)|g' \ - -e 's|{{TEST_TYPE}}|$(or $(TEST_TYPE),integration)|g' \ tests/pgsql.ini.tmpl > tests/pgsql.ini .PHONY: test-pgsql test-pgsql: integrations.pgsql.test generate-ini-pgsql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/pgsql.ini ./integrations.pgsql.test + GITEA_TEST_CONF=tests/pgsql.ini ./integrations.pgsql.test .PHONY: test-pgsql\#% test-pgsql\#%: integrations.pgsql.test generate-ini-pgsql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/pgsql.ini ./integrations.pgsql.test -test.run $(subst .,/,$*) + GITEA_TEST_CONF=tests/pgsql.ini ./integrations.pgsql.test -test.run $(subst .,/,$*) .PHONY: test-pgsql-migration test-pgsql-migration: migrations.pgsql.test migrations.individual.pgsql.test @@ -570,89 +516,53 @@ generate-ini-mssql: -e 's|{{TEST_MSSQL_DBNAME}}|${TEST_MSSQL_DBNAME}|g' \ -e 's|{{TEST_MSSQL_USERNAME}}|${TEST_MSSQL_USERNAME}|g' \ -e 's|{{TEST_MSSQL_PASSWORD}}|${TEST_MSSQL_PASSWORD}|g' \ - -e 's|{{REPO_TEST_DIR}}|${REPO_TEST_DIR}|g' \ + -e 's|{{WORK_PATH}}|$(CURDIR)/tests/$(or $(TEST_TYPE),integration)/gitea-$(or $(TEST_TYPE),integration)-mssql|g' \ -e 's|{{TEST_LOGGER}}|$(or $(TEST_LOGGER),test$(COMMA)file)|g' \ - -e 's|{{TEST_TYPE}}|$(or $(TEST_TYPE),integration)|g' \ tests/mssql.ini.tmpl > tests/mssql.ini .PHONY: test-mssql test-mssql: integrations.mssql.test generate-ini-mssql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mssql.ini ./integrations.mssql.test + GITEA_TEST_CONF=tests/mssql.ini ./integrations.mssql.test .PHONY: test-mssql\#% test-mssql\#%: integrations.mssql.test generate-ini-mssql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mssql.ini ./integrations.mssql.test -test.run $(subst .,/,$*) + GITEA_TEST_CONF=tests/mssql.ini ./integrations.mssql.test -test.run $(subst .,/,$*) .PHONY: test-mssql-migration test-mssql-migration: migrations.mssql.test migrations.individual.mssql.test .PHONY: playwright playwright: deps-frontend - $(NODE_VARS) pnpm exec playwright install $(PLAYWRIGHT_FLAGS) - -.PHONY: test-e2e% -test-e2e%: TEST_TYPE ?= e2e - # Clear display env variable. Otherwise, chromium tests can fail. - DISPLAY= + @# on GitHub Actions VMs, playwright's system deps are pre-installed + @$(NODE_VARS) pnpm exec playwright install $(if $(GITHUB_ACTIONS),,--with-deps) chromium $(if $(CI),firefox) $(PLAYWRIGHT_FLAGS) .PHONY: test-e2e -test-e2e: test-e2e-sqlite - -.PHONY: test-e2e-sqlite -test-e2e-sqlite: playwright e2e.sqlite.test generate-ini-sqlite - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/sqlite.ini ./e2e.sqlite.test - -.PHONY: test-e2e-sqlite\#% -test-e2e-sqlite\#%: playwright e2e.sqlite.test generate-ini-sqlite - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/sqlite.ini ./e2e.sqlite.test -test.run TestE2e/$* - -.PHONY: test-e2e-mysql -test-e2e-mysql: playwright e2e.mysql.test generate-ini-mysql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mysql.ini ./e2e.mysql.test - -.PHONY: test-e2e-mysql\#% -test-e2e-mysql\#%: playwright e2e.mysql.test generate-ini-mysql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mysql.ini ./e2e.mysql.test -test.run TestE2e/$* - -.PHONY: test-e2e-pgsql -test-e2e-pgsql: playwright e2e.pgsql.test generate-ini-pgsql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/pgsql.ini ./e2e.pgsql.test - -.PHONY: test-e2e-pgsql\#% -test-e2e-pgsql\#%: playwright e2e.pgsql.test generate-ini-pgsql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/pgsql.ini ./e2e.pgsql.test -test.run TestE2e/$* - -.PHONY: test-e2e-mssql -test-e2e-mssql: playwright e2e.mssql.test generate-ini-mssql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mssql.ini ./e2e.mssql.test - -.PHONY: test-e2e-mssql\#% -test-e2e-mssql\#%: playwright e2e.mssql.test generate-ini-mssql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mssql.ini ./e2e.mssql.test -test.run TestE2e/$* +test-e2e: playwright $(EXECUTABLE_E2E) + @EXECUTABLE=$(EXECUTABLE_E2E) ./tools/test-e2e.sh $(GITEA_TEST_E2E_FLAGS) .PHONY: bench-sqlite bench-sqlite: integrations.sqlite.test generate-ini-sqlite - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/sqlite.ini ./integrations.sqlite.test -test.cpuprofile=cpu.out -test.run DontRunTests -test.bench . + GITEA_TEST_CONF=tests/sqlite.ini ./integrations.sqlite.test -test.cpuprofile=cpu.out -test.run DontRunTests -test.bench . .PHONY: bench-mysql bench-mysql: integrations.mysql.test generate-ini-mysql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mysql.ini ./integrations.mysql.test -test.cpuprofile=cpu.out -test.run DontRunTests -test.bench . + GITEA_TEST_CONF=tests/mysql.ini ./integrations.mysql.test -test.cpuprofile=cpu.out -test.run DontRunTests -test.bench . .PHONY: bench-mssql bench-mssql: integrations.mssql.test generate-ini-mssql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mssql.ini ./integrations.mssql.test -test.cpuprofile=cpu.out -test.run DontRunTests -test.bench . + GITEA_TEST_CONF=tests/mssql.ini ./integrations.mssql.test -test.cpuprofile=cpu.out -test.run DontRunTests -test.bench . .PHONY: bench-pgsql bench-pgsql: integrations.pgsql.test generate-ini-pgsql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/pgsql.ini ./integrations.pgsql.test -test.cpuprofile=cpu.out -test.run DontRunTests -test.bench . + GITEA_TEST_CONF=tests/pgsql.ini ./integrations.pgsql.test -test.cpuprofile=cpu.out -test.run DontRunTests -test.bench . .PHONY: integration-test-coverage integration-test-coverage: integrations.cover.test generate-ini-mysql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mysql.ini ./integrations.cover.test -test.coverprofile=integration.coverage.out + GITEA_TEST_CONF=tests/mysql.ini ./integrations.cover.test -test.coverprofile=integration.coverage.out .PHONY: integration-test-coverage-sqlite integration-test-coverage-sqlite: integrations.cover.sqlite.test generate-ini-sqlite - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/sqlite.ini ./integrations.cover.sqlite.test -test.coverprofile=integration.coverage.out + GITEA_TEST_CONF=tests/sqlite.ini ./integrations.cover.sqlite.test -test.coverprofile=integration.coverage.out integrations.mysql.test: git-check $(GO_SOURCES) $(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/integration -o integrations.mysql.test @@ -675,66 +585,54 @@ integrations.cover.sqlite.test: git-check $(GO_SOURCES) .PHONY: migrations.mysql.test migrations.mysql.test: $(GO_SOURCES) generate-ini-mysql $(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/integration/migration-test -o migrations.mysql.test - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mysql.ini ./migrations.mysql.test + GITEA_TEST_CONF=tests/mysql.ini ./migrations.mysql.test .PHONY: migrations.pgsql.test migrations.pgsql.test: $(GO_SOURCES) generate-ini-pgsql $(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/integration/migration-test -o migrations.pgsql.test - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/pgsql.ini ./migrations.pgsql.test + GITEA_TEST_CONF=tests/pgsql.ini ./migrations.pgsql.test .PHONY: migrations.mssql.test migrations.mssql.test: $(GO_SOURCES) generate-ini-mssql $(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/integration/migration-test -o migrations.mssql.test - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mssql.ini ./migrations.mssql.test + GITEA_TEST_CONF=tests/mssql.ini ./migrations.mssql.test .PHONY: migrations.sqlite.test migrations.sqlite.test: $(GO_SOURCES) generate-ini-sqlite $(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/integration/migration-test -o migrations.sqlite.test -tags '$(TEST_TAGS)' - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/sqlite.ini ./migrations.sqlite.test + GITEA_TEST_CONF=tests/sqlite.ini ./migrations.sqlite.test .PHONY: migrations.individual.mysql.test -migrations.individual.mysql.test: $(GO_SOURCES) - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mysql.ini $(GO) test $(GOTESTFLAGS) -tags='$(TEST_TAGS)' -p 1 $(MIGRATE_TEST_PACKAGES) +migrations.individual.mysql.test: $(GO_SOURCES) generate-ini-mysql + GITEA_TEST_CONF=tests/mysql.ini $(GO) test $(GOTESTFLAGS) -tags='$(TEST_TAGS)' -p 1 $(MIGRATE_TEST_PACKAGES) .PHONY: migrations.individual.sqlite.test\#% migrations.individual.sqlite.test\#%: $(GO_SOURCES) generate-ini-sqlite - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/sqlite.ini $(GO) test $(GOTESTFLAGS) -tags '$(TEST_TAGS)' code.gitea.io/gitea/models/migrations/$* + GITEA_TEST_CONF=tests/sqlite.ini $(GO) test $(GOTESTFLAGS) -tags '$(TEST_TAGS)' code.gitea.io/gitea/models/migrations/$* .PHONY: migrations.individual.pgsql.test -migrations.individual.pgsql.test: $(GO_SOURCES) - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/pgsql.ini $(GO) test $(GOTESTFLAGS) -tags='$(TEST_TAGS)' -p 1 $(MIGRATE_TEST_PACKAGES) +migrations.individual.pgsql.test: $(GO_SOURCES) generate-ini-pgsql + GITEA_TEST_CONF=tests/pgsql.ini $(GO) test $(GOTESTFLAGS) -tags='$(TEST_TAGS)' -p 1 $(MIGRATE_TEST_PACKAGES) .PHONY: migrations.individual.pgsql.test\#% migrations.individual.pgsql.test\#%: $(GO_SOURCES) generate-ini-pgsql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/pgsql.ini $(GO) test $(GOTESTFLAGS) -tags '$(TEST_TAGS)' code.gitea.io/gitea/models/migrations/$* + GITEA_TEST_CONF=tests/pgsql.ini $(GO) test $(GOTESTFLAGS) -tags '$(TEST_TAGS)' code.gitea.io/gitea/models/migrations/$* .PHONY: migrations.individual.mssql.test migrations.individual.mssql.test: $(GO_SOURCES) generate-ini-mssql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mssql.ini $(GO) test $(GOTESTFLAGS) -tags='$(TEST_TAGS)' -p 1 $(MIGRATE_TEST_PACKAGES) + GITEA_TEST_CONF=tests/mssql.ini $(GO) test $(GOTESTFLAGS) -tags='$(TEST_TAGS)' -p 1 $(MIGRATE_TEST_PACKAGES) .PHONY: migrations.individual.mssql.test\#% migrations.individual.mssql.test\#%: $(GO_SOURCES) generate-ini-mssql - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/mssql.ini $(GO) test $(GOTESTFLAGS) -tags '$(TEST_TAGS)' code.gitea.io/gitea/models/migrations/$* + GITEA_TEST_CONF=tests/mssql.ini $(GO) test $(GOTESTFLAGS) -tags '$(TEST_TAGS)' code.gitea.io/gitea/models/migrations/$* .PHONY: migrations.individual.sqlite.test migrations.individual.sqlite.test: $(GO_SOURCES) generate-ini-sqlite - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/sqlite.ini $(GO) test $(GOTESTFLAGS) -tags='$(TEST_TAGS)' -p 1 $(MIGRATE_TEST_PACKAGES) + GITEA_TEST_CONF=tests/sqlite.ini $(GO) test $(GOTESTFLAGS) -tags='$(TEST_TAGS)' -p 1 $(MIGRATE_TEST_PACKAGES) .PHONY: migrations.individual.sqlite.test\#% migrations.individual.sqlite.test\#%: $(GO_SOURCES) generate-ini-sqlite - GITEA_ROOT="$(CURDIR)" GITEA_CONF=tests/sqlite.ini $(GO) test $(GOTESTFLAGS) -tags '$(TEST_TAGS)' code.gitea.io/gitea/models/migrations/$* - -e2e.mysql.test: $(GO_SOURCES) - $(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/e2e -o e2e.mysql.test - -e2e.pgsql.test: $(GO_SOURCES) - $(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/e2e -o e2e.pgsql.test - -e2e.mssql.test: $(GO_SOURCES) - $(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/e2e -o e2e.mssql.test - -e2e.sqlite.test: $(GO_SOURCES) - $(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/e2e -o e2e.sqlite.test -tags '$(TEST_TAGS)' + GITEA_TEST_CONF=tests/sqlite.ini $(GO) test $(GOTESTFLAGS) -tags '$(TEST_TAGS)' code.gitea.io/gitea/models/migrations/$* .PHONY: check check: test @@ -750,7 +648,7 @@ build: frontend backend ## build everything frontend: $(WEBPACK_DEST) ## build frontend files .PHONY: backend -backend: go-check generate-backend $(EXECUTABLE) ## build backend files +backend: generate-backend $(EXECUTABLE) ## build backend files # We generate the backend before the frontend in case we in future we want to generate things in the frontend from generated files in backend .PHONY: generate @@ -766,14 +664,17 @@ generate-go: $(TAGS_PREREQ) .PHONY: security-check security-check: - go run $(GOVULNCHECK_PACKAGE) -show color ./... + GOEXPERIMENT= go run $(GOVULNCHECK_PACKAGE) -show color ./... || true $(EXECUTABLE): $(GO_SOURCES) $(TAGS_PREREQ) ifneq ($(and $(STATIC),$(findstring pam,$(TAGS))),) - $(error pam support set via TAGS doesn't support static builds) + $(error pam support set via TAGS does not support static builds) endif CGO_ENABLED="$(CGO_ENABLED)" CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) build $(GOFLAGS) $(EXTRA_GOFLAGS) -tags '$(TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)' -o $@ +$(EXECUTABLE_E2E): $(GO_SOURCES) + CGO_ENABLED=1 $(GO) build $(GOFLAGS) $(EXTRA_GOFLAGS) -tags '$(TEST_TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)' -o $@ + .PHONY: release release: frontend generate release-windows release-linux release-darwin release-freebsd release-copy release-compress vendor release-sources release-check @@ -844,11 +745,8 @@ deps-tools: ## install tool dependencies $(GO) install $(MISSPELL_PACKAGE) & \ $(GO) install $(SWAGGER_PACKAGE) & \ $(GO) install $(XGO_PACKAGE) & \ - $(GO) install $(GO_LICENSES_PACKAGE) & \ $(GO) install $(GOVULNCHECK_PACKAGE) & \ $(GO) install $(ACTIONLINT_PACKAGE) & \ - $(GO) install $(GOPLS_PACKAGE) & \ - $(GO) install $(GOPLS_MODERNIZE_PACKAGE) & \ wait node_modules: pnpm-lock.yaml @@ -863,7 +761,7 @@ node_modules: pnpm-lock.yaml update: update-js update-py ## update js and py dependencies .PHONY: update-js -update-js: node-check | node_modules ## update js dependencies +update-js: node_modules ## update js dependencies $(NODE_VARS) pnpm exec updates -u -f package.json rm -rf node_modules pnpm-lock.yaml $(NODE_VARS) pnpm install @@ -872,7 +770,7 @@ update-js: node-check | node_modules ## update js dependencies @touch node_modules .PHONY: update-py -update-py: node-check | node_modules ## update py dependencies +update-py: node_modules ## update py dependencies $(NODE_VARS) pnpm exec updates -u -f pyproject.toml rm -rf .venv uv.lock uv sync @@ -882,14 +780,14 @@ update-py: node-check | node_modules ## update py dependencies webpack: $(WEBPACK_DEST) ## build webpack files $(WEBPACK_DEST): $(WEBPACK_SOURCES) $(WEBPACK_CONFIGS) pnpm-lock.yaml - @$(MAKE) -s node-check node_modules + @$(MAKE) -s node_modules @rm -rf $(WEBPACK_DEST_ENTRIES) @echo "Running webpack..." @BROWSERSLIST_IGNORE_OLD_DATA=true $(NODE_VARS) pnpm exec webpack --disable-interpret @touch $(WEBPACK_DEST) .PHONY: svg -svg: node-check | node_modules ## build svg files +svg: node_modules ## build svg files rm -rf $(SVG_DEST_DIR) node tools/generate-svg.ts @@ -914,16 +812,6 @@ lockfile-check: exit 1; \ fi -.PHONY: update-translations -update-translations: - mkdir -p ./translations - cd ./translations && curl -L https://crowdin.com/download/project/gitea.zip > gitea.zip && unzip gitea.zip - rm ./translations/gitea.zip - $(SED_INPLACE) -e 's/="/=/g' -e 's/"$$//g' ./translations/*.ini - $(SED_INPLACE) -e 's/\\"/"/g' ./translations/*.ini - mv ./translations/*.ini ./options/locale/ - rmdir ./translations - .PHONY: generate-gitignore generate-gitignore: ## update gitignore files $(GO) run build/generate-gitignores.go @@ -945,9 +833,6 @@ docker: docker build --disable-content-trust=false -t $(DOCKER_REF) . # support also build args docker build --build-arg GITEA_VERSION=v1.2.3 --build-arg TAGS="bindata sqlite sqlite_unlock_notify" . -# This endif closes the if at the top of the file -endif - # Disable parallel execution because it would break some targets that don't # specify exact dependencies like 'backend' which does currently not depend # on 'frontend' to enable Node.js-less builds from source tarballs. diff --git a/README.zh-cn.md b/README.zh-cn.md index f34b25b9454..8d9531e8e4e 100644 --- a/README.zh-cn.md +++ b/README.zh-cn.md @@ -46,7 +46,7 @@ `build` 目标分为两个子目标: - `make backend` 需要 [Go Stable](https://go.dev/dl/),所需版本在 [go.mod](/go.mod) 中定义。 -- `make frontend` 需要 [Node.js LTS](https://nodejs.org/en/download/) 或更高版本。 +- `make frontend` 需要 [Node.js LTS](https://nodejs.org/en/download/) 或更高版本以及 [pnpm](https://pnpm.io/installation)。 需要互联网连接来下载 go 和 npm 模块。从包含预构建前端文件的官方源代码压缩包构建时,不会触发 `frontend` 目标,因此可以在没有 Node.js 的情况下构建。 diff --git a/README.zh-tw.md b/README.zh-tw.md index 9de3f85dd56..875d31e28a8 100644 --- a/README.zh-tw.md +++ b/README.zh-tw.md @@ -46,7 +46,7 @@ `build` 目標分為兩個子目標: - `make backend` 需要 [Go Stable](https://go.dev/dl/),所需版本在 [go.mod](/go.mod) 中定義。 -- `make frontend` 需要 [Node.js LTS](https://nodejs.org/en/download/) 或更高版本。 +- `make frontend` 需要 [Node.js LTS](https://nodejs.org/en/download/) 或更高版本以及 [pnpm](https://pnpm.io/installation)。 需要互聯網連接來下載 go 和 npm 模塊。從包含預構建前端文件的官方源代碼壓縮包構建時,不會觸發 `frontend` 目標,因此可以在沒有 Node.js 的情況下構建。 diff --git a/assets/emoji.json b/assets/emoji.json index 28244caa651..2d6329e44f7 100644 --- a/assets/emoji.json +++ b/assets/emoji.json @@ -1 +1,11483 @@ -[{"emoji":"👍","aliases":["+1","thumbsup"]},{"emoji":"👎","aliases":["-1","thumbsdown"]},{"emoji":"💯","aliases":["100"]},{"emoji":"🔢","aliases":["1234"]},{"emoji":"🥇","aliases":["1st_place_medal"]},{"emoji":"🥈","aliases":["2nd_place_medal"]},{"emoji":"🥉","aliases":["3rd_place_medal"]},{"emoji":"🎱","aliases":["8ball"]},{"emoji":"🅰️","aliases":["a"]},{"emoji":"🆎","aliases":["ab"]},{"emoji":"🧮","aliases":["abacus"]},{"emoji":"🔤","aliases":["abc"]},{"emoji":"🔡","aliases":["abcd"]},{"emoji":"🉑","aliases":["accept"]},{"emoji":"🪗","aliases":["accordion"]},{"emoji":"🩹","aliases":["adhesive_bandage"]},{"emoji":"🧑","aliases":["adult"]},{"emoji":"🚡","aliases":["aerial_tramway"]},{"emoji":"🇦🇫","aliases":["afghanistan"]},{"emoji":"✈️","aliases":["airplane"]},{"emoji":"🇦🇽","aliases":["aland_islands"]},{"emoji":"⏰","aliases":["alarm_clock"]},{"emoji":"🇦🇱","aliases":["albania"]},{"emoji":"⚗️","aliases":["alembic"]},{"emoji":"🇩🇿","aliases":["algeria"]},{"emoji":"👽","aliases":["alien"]},{"emoji":"🚑","aliases":["ambulance"]},{"emoji":"🇦🇸","aliases":["american_samoa"]},{"emoji":"🏺","aliases":["amphora"]},{"emoji":"🫀","aliases":["anatomical_heart"]},{"emoji":"⚓","aliases":["anchor"]},{"emoji":"🇦🇩","aliases":["andorra"]},{"emoji":"👼","aliases":["angel"]},{"emoji":"💢","aliases":["anger"]},{"emoji":"🇦🇴","aliases":["angola"]},{"emoji":"😠","aliases":["angry"]},{"emoji":"🇦🇮","aliases":["anguilla"]},{"emoji":"😧","aliases":["anguished"]},{"emoji":"🐜","aliases":["ant"]},{"emoji":"🇦🇶","aliases":["antarctica"]},{"emoji":"🇦🇬","aliases":["antigua_barbuda"]},{"emoji":"🍎","aliases":["apple"]},{"emoji":"♒","aliases":["aquarius"]},{"emoji":"🇦🇷","aliases":["argentina"]},{"emoji":"♈","aliases":["aries"]},{"emoji":"🇦🇲","aliases":["armenia"]},{"emoji":"◀️","aliases":["arrow_backward"]},{"emoji":"⏬","aliases":["arrow_double_down"]},{"emoji":"⏫","aliases":["arrow_double_up"]},{"emoji":"⬇️","aliases":["arrow_down"]},{"emoji":"🔽","aliases":["arrow_down_small"]},{"emoji":"▶️","aliases":["arrow_forward"]},{"emoji":"⤵️","aliases":["arrow_heading_down"]},{"emoji":"⤴️","aliases":["arrow_heading_up"]},{"emoji":"⬅️","aliases":["arrow_left"]},{"emoji":"↙️","aliases":["arrow_lower_left"]},{"emoji":"↘️","aliases":["arrow_lower_right"]},{"emoji":"➡️","aliases":["arrow_right"]},{"emoji":"↪️","aliases":["arrow_right_hook"]},{"emoji":"⬆️","aliases":["arrow_up"]},{"emoji":"↕️","aliases":["arrow_up_down"]},{"emoji":"🔼","aliases":["arrow_up_small"]},{"emoji":"↖️","aliases":["arrow_upper_left"]},{"emoji":"↗️","aliases":["arrow_upper_right"]},{"emoji":"🔃","aliases":["arrows_clockwise"]},{"emoji":"🔄","aliases":["arrows_counterclockwise"]},{"emoji":"🎨","aliases":["art"]},{"emoji":"🚛","aliases":["articulated_lorry"]},{"emoji":"🛰️","aliases":["artificial_satellite"]},{"emoji":"🧑‍🎨","aliases":["artist"]},{"emoji":"🇦🇼","aliases":["aruba"]},{"emoji":"🇦🇨","aliases":["ascension_island"]},{"emoji":"*️⃣","aliases":["asterisk"]},{"emoji":"😲","aliases":["astonished"]},{"emoji":"🧑‍🚀","aliases":["astronaut"]},{"emoji":"👟","aliases":["athletic_shoe"]},{"emoji":"🏧","aliases":["atm"]},{"emoji":"⚛️","aliases":["atom_symbol"]},{"emoji":"🇦🇺","aliases":["australia"]},{"emoji":"🇦🇹","aliases":["austria"]},{"emoji":"🛺","aliases":["auto_rickshaw"]},{"emoji":"🥑","aliases":["avocado"]},{"emoji":"🪓","aliases":["axe"]},{"emoji":"🇦🇿","aliases":["azerbaijan"]},{"emoji":"🅱️","aliases":["b"]},{"emoji":"👶","aliases":["baby"]},{"emoji":"🍼","aliases":["baby_bottle"]},{"emoji":"🐤","aliases":["baby_chick"]},{"emoji":"🚼","aliases":["baby_symbol"]},{"emoji":"🔙","aliases":["back"]},{"emoji":"🥓","aliases":["bacon"]},{"emoji":"🦡","aliases":["badger"]},{"emoji":"🏸","aliases":["badminton"]},{"emoji":"🥯","aliases":["bagel"]},{"emoji":"🛄","aliases":["baggage_claim"]},{"emoji":"🥖","aliases":["baguette_bread"]},{"emoji":"🇧🇸","aliases":["bahamas"]},{"emoji":"🇧🇭","aliases":["bahrain"]},{"emoji":"⚖️","aliases":["balance_scale"]},{"emoji":"👨‍🦲","aliases":["bald_man"]},{"emoji":"👩‍🦲","aliases":["bald_woman"]},{"emoji":"🩰","aliases":["ballet_shoes"]},{"emoji":"🎈","aliases":["balloon"]},{"emoji":"🗳️","aliases":["ballot_box"]},{"emoji":"☑️","aliases":["ballot_box_with_check"]},{"emoji":"🎍","aliases":["bamboo"]},{"emoji":"🍌","aliases":["banana"]},{"emoji":"‼️","aliases":["bangbang"]},{"emoji":"🇧🇩","aliases":["bangladesh"]},{"emoji":"🪕","aliases":["banjo"]},{"emoji":"🏦","aliases":["bank"]},{"emoji":"📊","aliases":["bar_chart"]},{"emoji":"🇧🇧","aliases":["barbados"]},{"emoji":"💈","aliases":["barber"]},{"emoji":"⚾","aliases":["baseball"]},{"emoji":"🧺","aliases":["basket"]},{"emoji":"🏀","aliases":["basketball"]},{"emoji":"🦇","aliases":["bat"]},{"emoji":"🛀","aliases":["bath"]},{"emoji":"🛁","aliases":["bathtub"]},{"emoji":"🔋","aliases":["battery"]},{"emoji":"🏖️","aliases":["beach_umbrella"]},{"emoji":"🫘","aliases":["beans"]},{"emoji":"🐻","aliases":["bear"]},{"emoji":"🧔","aliases":["bearded_person"]},{"emoji":"🦫","aliases":["beaver"]},{"emoji":"🛏️","aliases":["bed"]},{"emoji":"🐝","aliases":["bee","honeybee"]},{"emoji":"🍺","aliases":["beer"]},{"emoji":"🍻","aliases":["beers"]},{"emoji":"🪲","aliases":["beetle"]},{"emoji":"🔰","aliases":["beginner"]},{"emoji":"🇧🇾","aliases":["belarus"]},{"emoji":"🇧🇪","aliases":["belgium"]},{"emoji":"🇧🇿","aliases":["belize"]},{"emoji":"🔔","aliases":["bell"]},{"emoji":"🫑","aliases":["bell_pepper"]},{"emoji":"🛎️","aliases":["bellhop_bell"]},{"emoji":"🇧🇯","aliases":["benin"]},{"emoji":"🍱","aliases":["bento"]},{"emoji":"🇧🇲","aliases":["bermuda"]},{"emoji":"🧃","aliases":["beverage_box"]},{"emoji":"🇧🇹","aliases":["bhutan"]},{"emoji":"🚴","aliases":["bicyclist"]},{"emoji":"🚲","aliases":["bike"]},{"emoji":"🚴‍♂️","aliases":["biking_man"]},{"emoji":"🚴‍♀️","aliases":["biking_woman"]},{"emoji":"👙","aliases":["bikini"]},{"emoji":"🧢","aliases":["billed_cap"]},{"emoji":"☣️","aliases":["biohazard"]},{"emoji":"🐦","aliases":["bird"]},{"emoji":"🎂","aliases":["birthday"]},{"emoji":"🦬","aliases":["bison"]},{"emoji":"🫦","aliases":["biting_lip"]},{"emoji":"🐦‍⬛","aliases":["black_bird"]},{"emoji":"🐈‍⬛","aliases":["black_cat"]},{"emoji":"⚫","aliases":["black_circle"]},{"emoji":"🏴","aliases":["black_flag"]},{"emoji":"🖤","aliases":["black_heart"]},{"emoji":"🃏","aliases":["black_joker"]},{"emoji":"⬛","aliases":["black_large_square"]},{"emoji":"◾","aliases":["black_medium_small_square"]},{"emoji":"◼️","aliases":["black_medium_square"]},{"emoji":"✒️","aliases":["black_nib"]},{"emoji":"▪️","aliases":["black_small_square"]},{"emoji":"🔲","aliases":["black_square_button"]},{"emoji":"👱‍♂️","aliases":["blond_haired_man"]},{"emoji":"👱","aliases":["blond_haired_person"]},{"emoji":"👱‍♀️","aliases":["blond_haired_woman","blonde_woman"]},{"emoji":"🌼","aliases":["blossom"]},{"emoji":"🐡","aliases":["blowfish"]},{"emoji":"📘","aliases":["blue_book"]},{"emoji":"🚙","aliases":["blue_car"]},{"emoji":"💙","aliases":["blue_heart"]},{"emoji":"🟦","aliases":["blue_square"]},{"emoji":"🫐","aliases":["blueberries"]},{"emoji":"😊","aliases":["blush"]},{"emoji":"🐗","aliases":["boar"]},{"emoji":"⛵","aliases":["boat","sailboat"]},{"emoji":"🇧🇴","aliases":["bolivia"]},{"emoji":"💣","aliases":["bomb"]},{"emoji":"🦴","aliases":["bone"]},{"emoji":"📖","aliases":["book","open_book"]},{"emoji":"🔖","aliases":["bookmark"]},{"emoji":"📑","aliases":["bookmark_tabs"]},{"emoji":"📚","aliases":["books"]},{"emoji":"💥","aliases":["boom","collision"]},{"emoji":"🪃","aliases":["boomerang"]},{"emoji":"👢","aliases":["boot"]},{"emoji":"🇧🇦","aliases":["bosnia_herzegovina"]},{"emoji":"🇧🇼","aliases":["botswana"]},{"emoji":"⛹️‍♂️","aliases":["bouncing_ball_man","basketball_man"]},{"emoji":"⛹️","aliases":["bouncing_ball_person"]},{"emoji":"⛹️‍♀️","aliases":["bouncing_ball_woman","basketball_woman"]},{"emoji":"💐","aliases":["bouquet"]},{"emoji":"🇧🇻","aliases":["bouvet_island"]},{"emoji":"🙇","aliases":["bow"]},{"emoji":"🏹","aliases":["bow_and_arrow"]},{"emoji":"🙇‍♂️","aliases":["bowing_man"]},{"emoji":"🙇‍♀️","aliases":["bowing_woman"]},{"emoji":"🥣","aliases":["bowl_with_spoon"]},{"emoji":"🎳","aliases":["bowling"]},{"emoji":"🥊","aliases":["boxing_glove"]},{"emoji":"👦","aliases":["boy"]},{"emoji":"🧠","aliases":["brain"]},{"emoji":"🇧🇷","aliases":["brazil"]},{"emoji":"🍞","aliases":["bread"]},{"emoji":"🤱","aliases":["breast_feeding"]},{"emoji":"🧱","aliases":["bricks"]},{"emoji":"🌉","aliases":["bridge_at_night"]},{"emoji":"💼","aliases":["briefcase"]},{"emoji":"🇮🇴","aliases":["british_indian_ocean_territory"]},{"emoji":"🇻🇬","aliases":["british_virgin_islands"]},{"emoji":"🥦","aliases":["broccoli"]},{"emoji":"💔","aliases":["broken_heart"]},{"emoji":"🧹","aliases":["broom"]},{"emoji":"🟤","aliases":["brown_circle"]},{"emoji":"🤎","aliases":["brown_heart"]},{"emoji":"🟫","aliases":["brown_square"]},{"emoji":"🇧🇳","aliases":["brunei"]},{"emoji":"🧋","aliases":["bubble_tea"]},{"emoji":"🫧","aliases":["bubbles"]},{"emoji":"🪣","aliases":["bucket"]},{"emoji":"🐛","aliases":["bug"]},{"emoji":"🏗️","aliases":["building_construction"]},{"emoji":"💡","aliases":["bulb"]},{"emoji":"🇧🇬","aliases":["bulgaria"]},{"emoji":"🚅","aliases":["bullettrain_front"]},{"emoji":"🚄","aliases":["bullettrain_side"]},{"emoji":"🇧🇫","aliases":["burkina_faso"]},{"emoji":"🌯","aliases":["burrito"]},{"emoji":"🇧🇮","aliases":["burundi"]},{"emoji":"🚌","aliases":["bus"]},{"emoji":"🕴️","aliases":["business_suit_levitating"]},{"emoji":"🚏","aliases":["busstop"]},{"emoji":"👤","aliases":["bust_in_silhouette"]},{"emoji":"👥","aliases":["busts_in_silhouette"]},{"emoji":"🧈","aliases":["butter"]},{"emoji":"🦋","aliases":["butterfly"]},{"emoji":"🌵","aliases":["cactus"]},{"emoji":"🍰","aliases":["cake"]},{"emoji":"📆","aliases":["calendar"]},{"emoji":"🤙","aliases":["call_me_hand"]},{"emoji":"📲","aliases":["calling"]},{"emoji":"🇰🇭","aliases":["cambodia"]},{"emoji":"🐫","aliases":["camel"]},{"emoji":"📷","aliases":["camera"]},{"emoji":"📸","aliases":["camera_flash"]},{"emoji":"🇨🇲","aliases":["cameroon"]},{"emoji":"🏕️","aliases":["camping"]},{"emoji":"🇨🇦","aliases":["canada"]},{"emoji":"🇮🇨","aliases":["canary_islands"]},{"emoji":"♋","aliases":["cancer"]},{"emoji":"🕯️","aliases":["candle"]},{"emoji":"🍬","aliases":["candy"]},{"emoji":"🥫","aliases":["canned_food"]},{"emoji":"🛶","aliases":["canoe"]},{"emoji":"🇨🇻","aliases":["cape_verde"]},{"emoji":"🔠","aliases":["capital_abcd"]},{"emoji":"♑","aliases":["capricorn"]},{"emoji":"🚗","aliases":["car","red_car"]},{"emoji":"🗃️","aliases":["card_file_box"]},{"emoji":"📇","aliases":["card_index"]},{"emoji":"🗂️","aliases":["card_index_dividers"]},{"emoji":"🇧🇶","aliases":["caribbean_netherlands"]},{"emoji":"🎠","aliases":["carousel_horse"]},{"emoji":"🪚","aliases":["carpentry_saw"]},{"emoji":"🥕","aliases":["carrot"]},{"emoji":"🤸","aliases":["cartwheeling"]},{"emoji":"🐱","aliases":["cat"]},{"emoji":"🐈","aliases":["cat2"]},{"emoji":"🇰🇾","aliases":["cayman_islands"]},{"emoji":"💿","aliases":["cd"]},{"emoji":"🇨🇫","aliases":["central_african_republic"]},{"emoji":"🇪🇦","aliases":["ceuta_melilla"]},{"emoji":"🇹🇩","aliases":["chad"]},{"emoji":"⛓️","aliases":["chains"]},{"emoji":"🪑","aliases":["chair"]},{"emoji":"🍾","aliases":["champagne"]},{"emoji":"💹","aliases":["chart"]},{"emoji":"📉","aliases":["chart_with_downwards_trend"]},{"emoji":"📈","aliases":["chart_with_upwards_trend"]},{"emoji":"🏁","aliases":["checkered_flag"]},{"emoji":"🧀","aliases":["cheese"]},{"emoji":"🍒","aliases":["cherries"]},{"emoji":"🌸","aliases":["cherry_blossom"]},{"emoji":"♟️","aliases":["chess_pawn"]},{"emoji":"🌰","aliases":["chestnut"]},{"emoji":"🐔","aliases":["chicken"]},{"emoji":"🧒","aliases":["child"]},{"emoji":"🚸","aliases":["children_crossing"]},{"emoji":"🇨🇱","aliases":["chile"]},{"emoji":"🐿️","aliases":["chipmunk"]},{"emoji":"🍫","aliases":["chocolate_bar"]},{"emoji":"🥢","aliases":["chopsticks"]},{"emoji":"🇨🇽","aliases":["christmas_island"]},{"emoji":"🎄","aliases":["christmas_tree"]},{"emoji":"⛪","aliases":["church"]},{"emoji":"🎦","aliases":["cinema"]},{"emoji":"🎪","aliases":["circus_tent"]},{"emoji":"🌇","aliases":["city_sunrise"]},{"emoji":"🌆","aliases":["city_sunset"]},{"emoji":"🏙️","aliases":["cityscape"]},{"emoji":"🆑","aliases":["cl"]},{"emoji":"🗜️","aliases":["clamp"]},{"emoji":"👏","aliases":["clap"]},{"emoji":"🎬","aliases":["clapper"]},{"emoji":"🏛️","aliases":["classical_building"]},{"emoji":"🧗","aliases":["climbing"]},{"emoji":"🧗‍♂️","aliases":["climbing_man"]},{"emoji":"🧗‍♀️","aliases":["climbing_woman"]},{"emoji":"🥂","aliases":["clinking_glasses"]},{"emoji":"📋","aliases":["clipboard"]},{"emoji":"🇨🇵","aliases":["clipperton_island"]},{"emoji":"🕐","aliases":["clock1"]},{"emoji":"🕙","aliases":["clock10"]},{"emoji":"🕥","aliases":["clock1030"]},{"emoji":"🕚","aliases":["clock11"]},{"emoji":"🕦","aliases":["clock1130"]},{"emoji":"🕛","aliases":["clock12"]},{"emoji":"🕧","aliases":["clock1230"]},{"emoji":"🕜","aliases":["clock130"]},{"emoji":"🕑","aliases":["clock2"]},{"emoji":"🕝","aliases":["clock230"]},{"emoji":"🕒","aliases":["clock3"]},{"emoji":"🕞","aliases":["clock330"]},{"emoji":"🕓","aliases":["clock4"]},{"emoji":"🕟","aliases":["clock430"]},{"emoji":"🕔","aliases":["clock5"]},{"emoji":"🕠","aliases":["clock530"]},{"emoji":"🕕","aliases":["clock6"]},{"emoji":"🕡","aliases":["clock630"]},{"emoji":"🕖","aliases":["clock7"]},{"emoji":"🕢","aliases":["clock730"]},{"emoji":"🕗","aliases":["clock8"]},{"emoji":"🕣","aliases":["clock830"]},{"emoji":"🕘","aliases":["clock9"]},{"emoji":"🕤","aliases":["clock930"]},{"emoji":"📕","aliases":["closed_book"]},{"emoji":"🔐","aliases":["closed_lock_with_key"]},{"emoji":"🌂","aliases":["closed_umbrella"]},{"emoji":"☁️","aliases":["cloud"]},{"emoji":"🌩️","aliases":["cloud_with_lightning"]},{"emoji":"⛈️","aliases":["cloud_with_lightning_and_rain"]},{"emoji":"🌧️","aliases":["cloud_with_rain"]},{"emoji":"🌨️","aliases":["cloud_with_snow"]},{"emoji":"🤡","aliases":["clown_face"]},{"emoji":"♣️","aliases":["clubs"]},{"emoji":"🇨🇳","aliases":["cn"]},{"emoji":"🧥","aliases":["coat"]},{"emoji":"🪳","aliases":["cockroach"]},{"emoji":"🍸","aliases":["cocktail"]},{"emoji":"🥥","aliases":["coconut"]},{"emoji":"🇨🇨","aliases":["cocos_islands"]},{"emoji":"☕","aliases":["coffee"]},{"emoji":"⚰️","aliases":["coffin"]},{"emoji":"🪙","aliases":["coin"]},{"emoji":"🥶","aliases":["cold_face"]},{"emoji":"😰","aliases":["cold_sweat"]},{"emoji":"🇨🇴","aliases":["colombia"]},{"emoji":"☄️","aliases":["comet"]},{"emoji":"🇰🇲","aliases":["comoros"]},{"emoji":"🧭","aliases":["compass"]},{"emoji":"💻","aliases":["computer"]},{"emoji":"🖱️","aliases":["computer_mouse"]},{"emoji":"🎊","aliases":["confetti_ball"]},{"emoji":"😖","aliases":["confounded"]},{"emoji":"😕","aliases":["confused"]},{"emoji":"🇨🇬","aliases":["congo_brazzaville"]},{"emoji":"🇨🇩","aliases":["congo_kinshasa"]},{"emoji":"㊗️","aliases":["congratulations"]},{"emoji":"🚧","aliases":["construction"]},{"emoji":"👷","aliases":["construction_worker"]},{"emoji":"👷‍♂️","aliases":["construction_worker_man"]},{"emoji":"👷‍♀️","aliases":["construction_worker_woman"]},{"emoji":"🎛️","aliases":["control_knobs"]},{"emoji":"🏪","aliases":["convenience_store"]},{"emoji":"🧑‍🍳","aliases":["cook"]},{"emoji":"🇨🇰","aliases":["cook_islands"]},{"emoji":"🍪","aliases":["cookie"]},{"emoji":"🆒","aliases":["cool"]},{"emoji":"©️","aliases":["copyright"]},{"emoji":"🪸","aliases":["coral"]},{"emoji":"🌽","aliases":["corn"]},{"emoji":"🇨🇷","aliases":["costa_rica"]},{"emoji":"🇨🇮","aliases":["cote_divoire"]},{"emoji":"🛋️","aliases":["couch_and_lamp"]},{"emoji":"👫","aliases":["couple"]},{"emoji":"💑","aliases":["couple_with_heart"]},{"emoji":"👨‍❤️‍👨","aliases":["couple_with_heart_man_man"]},{"emoji":"👩‍❤️‍👨","aliases":["couple_with_heart_woman_man"]},{"emoji":"👩‍❤️‍👩","aliases":["couple_with_heart_woman_woman"]},{"emoji":"💏","aliases":["couplekiss"]},{"emoji":"👨‍❤️‍💋‍👨","aliases":["couplekiss_man_man"]},{"emoji":"👩‍❤️‍💋‍👨","aliases":["couplekiss_man_woman"]},{"emoji":"👩‍❤️‍💋‍👩","aliases":["couplekiss_woman_woman"]},{"emoji":"🐮","aliases":["cow"]},{"emoji":"🐄","aliases":["cow2"]},{"emoji":"🤠","aliases":["cowboy_hat_face"]},{"emoji":"🦀","aliases":["crab"]},{"emoji":"🖍️","aliases":["crayon"]},{"emoji":"💳","aliases":["credit_card"]},{"emoji":"🌙","aliases":["crescent_moon"]},{"emoji":"🦗","aliases":["cricket"]},{"emoji":"🏏","aliases":["cricket_game"]},{"emoji":"🇭🇷","aliases":["croatia"]},{"emoji":"🐊","aliases":["crocodile"]},{"emoji":"🥐","aliases":["croissant"]},{"emoji":"🤞","aliases":["crossed_fingers"]},{"emoji":"🎌","aliases":["crossed_flags"]},{"emoji":"⚔️","aliases":["crossed_swords"]},{"emoji":"👑","aliases":["crown"]},{"emoji":"🩼","aliases":["crutch"]},{"emoji":"😢","aliases":["cry"]},{"emoji":"😿","aliases":["crying_cat_face"]},{"emoji":"🔮","aliases":["crystal_ball"]},{"emoji":"🇨🇺","aliases":["cuba"]},{"emoji":"🥒","aliases":["cucumber"]},{"emoji":"🥤","aliases":["cup_with_straw"]},{"emoji":"🧁","aliases":["cupcake"]},{"emoji":"💘","aliases":["cupid"]},{"emoji":"🇨🇼","aliases":["curacao"]},{"emoji":"🥌","aliases":["curling_stone"]},{"emoji":"👨‍🦱","aliases":["curly_haired_man"]},{"emoji":"👩‍🦱","aliases":["curly_haired_woman"]},{"emoji":"➰","aliases":["curly_loop"]},{"emoji":"💱","aliases":["currency_exchange"]},{"emoji":"🍛","aliases":["curry"]},{"emoji":"🤬","aliases":["cursing_face"]},{"emoji":"🍮","aliases":["custard"]},{"emoji":"🛃","aliases":["customs"]},{"emoji":"🥩","aliases":["cut_of_meat"]},{"emoji":"🌀","aliases":["cyclone"]},{"emoji":"🇨🇾","aliases":["cyprus"]},{"emoji":"🇨🇿","aliases":["czech_republic"]},{"emoji":"🗡️","aliases":["dagger"]},{"emoji":"👯","aliases":["dancers"]},{"emoji":"👯‍♂️","aliases":["dancing_men"]},{"emoji":"👯‍♀️","aliases":["dancing_women"]},{"emoji":"🍡","aliases":["dango"]},{"emoji":"🕶️","aliases":["dark_sunglasses"]},{"emoji":"🎯","aliases":["dart"]},{"emoji":"💨","aliases":["dash"]},{"emoji":"📅","aliases":["date"]},{"emoji":"🇩🇪","aliases":["de"]},{"emoji":"🧏‍♂️","aliases":["deaf_man"]},{"emoji":"🧏","aliases":["deaf_person"]},{"emoji":"🧏‍♀️","aliases":["deaf_woman"]},{"emoji":"🌳","aliases":["deciduous_tree"]},{"emoji":"🦌","aliases":["deer"]},{"emoji":"🇩🇰","aliases":["denmark"]},{"emoji":"🏬","aliases":["department_store"]},{"emoji":"🏚️","aliases":["derelict_house"]},{"emoji":"🏜️","aliases":["desert"]},{"emoji":"🏝️","aliases":["desert_island"]},{"emoji":"🖥️","aliases":["desktop_computer"]},{"emoji":"🕵️","aliases":["detective"]},{"emoji":"💠","aliases":["diamond_shape_with_a_dot_inside"]},{"emoji":"♦️","aliases":["diamonds"]},{"emoji":"🇩🇬","aliases":["diego_garcia"]},{"emoji":"😞","aliases":["disappointed"]},{"emoji":"😥","aliases":["disappointed_relieved"]},{"emoji":"🥸","aliases":["disguised_face"]},{"emoji":"🤿","aliases":["diving_mask"]},{"emoji":"🪔","aliases":["diya_lamp"]},{"emoji":"💫","aliases":["dizzy"]},{"emoji":"😵","aliases":["dizzy_face"]},{"emoji":"🇩🇯","aliases":["djibouti"]},{"emoji":"🧬","aliases":["dna"]},{"emoji":"🚯","aliases":["do_not_litter"]},{"emoji":"🦤","aliases":["dodo"]},{"emoji":"🐶","aliases":["dog"]},{"emoji":"🐕","aliases":["dog2"]},{"emoji":"💵","aliases":["dollar"]},{"emoji":"🎎","aliases":["dolls"]},{"emoji":"🐬","aliases":["dolphin","flipper"]},{"emoji":"🇩🇲","aliases":["dominica"]},{"emoji":"🇩🇴","aliases":["dominican_republic"]},{"emoji":"🫏","aliases":["donkey"]},{"emoji":"🚪","aliases":["door"]},{"emoji":"🫥","aliases":["dotted_line_face"]},{"emoji":"🍩","aliases":["doughnut"]},{"emoji":"🕊️","aliases":["dove"]},{"emoji":"🐉","aliases":["dragon"]},{"emoji":"🐲","aliases":["dragon_face"]},{"emoji":"👗","aliases":["dress"]},{"emoji":"🐪","aliases":["dromedary_camel"]},{"emoji":"🤤","aliases":["drooling_face"]},{"emoji":"🩸","aliases":["drop_of_blood"]},{"emoji":"💧","aliases":["droplet"]},{"emoji":"🥁","aliases":["drum"]},{"emoji":"🦆","aliases":["duck"]},{"emoji":"🥟","aliases":["dumpling"]},{"emoji":"📀","aliases":["dvd"]},{"emoji":"🦅","aliases":["eagle"]},{"emoji":"👂","aliases":["ear"]},{"emoji":"🌾","aliases":["ear_of_rice"]},{"emoji":"🦻","aliases":["ear_with_hearing_aid"]},{"emoji":"🌍","aliases":["earth_africa"]},{"emoji":"🌎","aliases":["earth_americas"]},{"emoji":"🌏","aliases":["earth_asia"]},{"emoji":"🇪🇨","aliases":["ecuador"]},{"emoji":"🥚","aliases":["egg"]},{"emoji":"🍆","aliases":["eggplant"]},{"emoji":"🇪🇬","aliases":["egypt"]},{"emoji":"8️⃣","aliases":["eight"]},{"emoji":"✴️","aliases":["eight_pointed_black_star"]},{"emoji":"✳️","aliases":["eight_spoked_asterisk"]},{"emoji":"⏏️","aliases":["eject_button"]},{"emoji":"🇸🇻","aliases":["el_salvador"]},{"emoji":"🔌","aliases":["electric_plug"]},{"emoji":"🐘","aliases":["elephant"]},{"emoji":"🛗","aliases":["elevator"]},{"emoji":"🧝","aliases":["elf"]},{"emoji":"🧝‍♂️","aliases":["elf_man"]},{"emoji":"🧝‍♀️","aliases":["elf_woman"]},{"emoji":"📧","aliases":["email","e-mail"]},{"emoji":"🪹","aliases":["empty_nest"]},{"emoji":"🔚","aliases":["end"]},{"emoji":"🏴󠁧󠁢󠁥󠁮󠁧󠁿","aliases":["england"]},{"emoji":"✉️","aliases":["envelope"]},{"emoji":"📩","aliases":["envelope_with_arrow"]},{"emoji":"🇬🇶","aliases":["equatorial_guinea"]},{"emoji":"🇪🇷","aliases":["eritrea"]},{"emoji":"🇪🇸","aliases":["es"]},{"emoji":"🇪🇪","aliases":["estonia"]},{"emoji":"🇪🇹","aliases":["ethiopia"]},{"emoji":"🇪🇺","aliases":["eu","european_union"]},{"emoji":"💶","aliases":["euro"]},{"emoji":"🏰","aliases":["european_castle"]},{"emoji":"🏤","aliases":["european_post_office"]},{"emoji":"🌲","aliases":["evergreen_tree"]},{"emoji":"❗","aliases":["exclamation","heavy_exclamation_mark"]},{"emoji":"🤯","aliases":["exploding_head"]},{"emoji":"😑","aliases":["expressionless"]},{"emoji":"👁️","aliases":["eye"]},{"emoji":"👁️‍🗨️","aliases":["eye_speech_bubble"]},{"emoji":"👓","aliases":["eyeglasses"]},{"emoji":"👀","aliases":["eyes"]},{"emoji":"😮‍💨","aliases":["face_exhaling"]},{"emoji":"🥹","aliases":["face_holding_back_tears"]},{"emoji":"😶‍🌫️","aliases":["face_in_clouds"]},{"emoji":"🫤","aliases":["face_with_diagonal_mouth"]},{"emoji":"🤕","aliases":["face_with_head_bandage"]},{"emoji":"🫢","aliases":["face_with_open_eyes_and_hand_over_mouth"]},{"emoji":"🫣","aliases":["face_with_peeking_eye"]},{"emoji":"😵‍💫","aliases":["face_with_spiral_eyes"]},{"emoji":"🤒","aliases":["face_with_thermometer"]},{"emoji":"🤦","aliases":["facepalm"]},{"emoji":"🏭","aliases":["factory"]},{"emoji":"🧑‍🏭","aliases":["factory_worker"]},{"emoji":"🧚","aliases":["fairy"]},{"emoji":"🧚‍♂️","aliases":["fairy_man"]},{"emoji":"🧚‍♀️","aliases":["fairy_woman"]},{"emoji":"🧆","aliases":["falafel"]},{"emoji":"🇫🇰","aliases":["falkland_islands"]},{"emoji":"🍂","aliases":["fallen_leaf"]},{"emoji":"👪","aliases":["family"]},{"emoji":"👨‍👦","aliases":["family_man_boy"]},{"emoji":"👨‍👦‍👦","aliases":["family_man_boy_boy"]},{"emoji":"👨‍👧","aliases":["family_man_girl"]},{"emoji":"👨‍👧‍👦","aliases":["family_man_girl_boy"]},{"emoji":"👨‍👧‍👧","aliases":["family_man_girl_girl"]},{"emoji":"👨‍👨‍👦","aliases":["family_man_man_boy"]},{"emoji":"👨‍👨‍👦‍👦","aliases":["family_man_man_boy_boy"]},{"emoji":"👨‍👨‍👧","aliases":["family_man_man_girl"]},{"emoji":"👨‍👨‍👧‍👦","aliases":["family_man_man_girl_boy"]},{"emoji":"👨‍👨‍👧‍👧","aliases":["family_man_man_girl_girl"]},{"emoji":"👨‍👩‍👦","aliases":["family_man_woman_boy"]},{"emoji":"👨‍👩‍👦‍👦","aliases":["family_man_woman_boy_boy"]},{"emoji":"👨‍👩‍👧","aliases":["family_man_woman_girl"]},{"emoji":"👨‍👩‍👧‍👦","aliases":["family_man_woman_girl_boy"]},{"emoji":"👨‍👩‍👧‍👧","aliases":["family_man_woman_girl_girl"]},{"emoji":"👩‍👦","aliases":["family_woman_boy"]},{"emoji":"👩‍👦‍👦","aliases":["family_woman_boy_boy"]},{"emoji":"👩‍👧","aliases":["family_woman_girl"]},{"emoji":"👩‍👧‍👦","aliases":["family_woman_girl_boy"]},{"emoji":"👩‍👧‍👧","aliases":["family_woman_girl_girl"]},{"emoji":"👩‍👩‍👦","aliases":["family_woman_woman_boy"]},{"emoji":"👩‍👩‍👦‍👦","aliases":["family_woman_woman_boy_boy"]},{"emoji":"👩‍👩‍👧","aliases":["family_woman_woman_girl"]},{"emoji":"👩‍👩‍👧‍👦","aliases":["family_woman_woman_girl_boy"]},{"emoji":"👩‍👩‍👧‍👧","aliases":["family_woman_woman_girl_girl"]},{"emoji":"🧑‍🌾","aliases":["farmer"]},{"emoji":"🇫🇴","aliases":["faroe_islands"]},{"emoji":"⏩","aliases":["fast_forward"]},{"emoji":"📠","aliases":["fax"]},{"emoji":"😨","aliases":["fearful"]},{"emoji":"🪶","aliases":["feather"]},{"emoji":"🐾","aliases":["feet","paw_prints"]},{"emoji":"🕵️‍♀️","aliases":["female_detective"]},{"emoji":"♀️","aliases":["female_sign"]},{"emoji":"🎡","aliases":["ferris_wheel"]},{"emoji":"⛴️","aliases":["ferry"]},{"emoji":"🏑","aliases":["field_hockey"]},{"emoji":"🇫🇯","aliases":["fiji"]},{"emoji":"🗄️","aliases":["file_cabinet"]},{"emoji":"📁","aliases":["file_folder"]},{"emoji":"📽️","aliases":["film_projector"]},{"emoji":"🎞️","aliases":["film_strip"]},{"emoji":"🇫🇮","aliases":["finland"]},{"emoji":"🔥","aliases":["fire"]},{"emoji":"🚒","aliases":["fire_engine"]},{"emoji":"🧯","aliases":["fire_extinguisher"]},{"emoji":"🧨","aliases":["firecracker"]},{"emoji":"🧑‍🚒","aliases":["firefighter"]},{"emoji":"🎆","aliases":["fireworks"]},{"emoji":"🌓","aliases":["first_quarter_moon"]},{"emoji":"🌛","aliases":["first_quarter_moon_with_face"]},{"emoji":"🐟","aliases":["fish"]},{"emoji":"🍥","aliases":["fish_cake"]},{"emoji":"🎣","aliases":["fishing_pole_and_fish"]},{"emoji":"🤛","aliases":["fist_left"]},{"emoji":"👊","aliases":["fist_oncoming","facepunch","punch"]},{"emoji":"✊","aliases":["fist_raised","fist"]},{"emoji":"🤜","aliases":["fist_right"]},{"emoji":"5️⃣","aliases":["five"]},{"emoji":"🎏","aliases":["flags"]},{"emoji":"🦩","aliases":["flamingo"]},{"emoji":"🔦","aliases":["flashlight"]},{"emoji":"🥿","aliases":["flat_shoe"]},{"emoji":"🫓","aliases":["flatbread"]},{"emoji":"⚜️","aliases":["fleur_de_lis"]},{"emoji":"🛬","aliases":["flight_arrival"]},{"emoji":"🛫","aliases":["flight_departure"]},{"emoji":"💾","aliases":["floppy_disk"]},{"emoji":"🎴","aliases":["flower_playing_cards"]},{"emoji":"😳","aliases":["flushed"]},{"emoji":"🪈","aliases":["flute"]},{"emoji":"🪰","aliases":["fly"]},{"emoji":"🥏","aliases":["flying_disc"]},{"emoji":"🛸","aliases":["flying_saucer"]},{"emoji":"🌫️","aliases":["fog"]},{"emoji":"🌁","aliases":["foggy"]},{"emoji":"🪭","aliases":["folding_hand_fan"]},{"emoji":"🫕","aliases":["fondue"]},{"emoji":"🦶","aliases":["foot"]},{"emoji":"🏈","aliases":["football"]},{"emoji":"👣","aliases":["footprints"]},{"emoji":"🍴","aliases":["fork_and_knife"]},{"emoji":"🥠","aliases":["fortune_cookie"]},{"emoji":"⛲","aliases":["fountain"]},{"emoji":"🖋️","aliases":["fountain_pen"]},{"emoji":"4️⃣","aliases":["four"]},{"emoji":"🍀","aliases":["four_leaf_clover"]},{"emoji":"🦊","aliases":["fox_face"]},{"emoji":"🇫🇷","aliases":["fr"]},{"emoji":"🖼️","aliases":["framed_picture"]},{"emoji":"🆓","aliases":["free"]},{"emoji":"🇬🇫","aliases":["french_guiana"]},{"emoji":"🇵🇫","aliases":["french_polynesia"]},{"emoji":"🇹🇫","aliases":["french_southern_territories"]},{"emoji":"🍳","aliases":["fried_egg"]},{"emoji":"🍤","aliases":["fried_shrimp"]},{"emoji":"🍟","aliases":["fries"]},{"emoji":"🐸","aliases":["frog"]},{"emoji":"😦","aliases":["frowning"]},{"emoji":"☹️","aliases":["frowning_face"]},{"emoji":"🙍‍♂️","aliases":["frowning_man"]},{"emoji":"🙍","aliases":["frowning_person"]},{"emoji":"🙍‍♀️","aliases":["frowning_woman"]},{"emoji":"⛽","aliases":["fuelpump"]},{"emoji":"🌕","aliases":["full_moon"]},{"emoji":"🌝","aliases":["full_moon_with_face"]},{"emoji":"⚱️","aliases":["funeral_urn"]},{"emoji":"🇬🇦","aliases":["gabon"]},{"emoji":"🇬🇲","aliases":["gambia"]},{"emoji":"🎲","aliases":["game_die"]},{"emoji":"🧄","aliases":["garlic"]},{"emoji":"🇬🇧","aliases":["gb","uk"]},{"emoji":"⚙️","aliases":["gear"]},{"emoji":"💎","aliases":["gem"]},{"emoji":"♊","aliases":["gemini"]},{"emoji":"🧞","aliases":["genie"]},{"emoji":"🧞‍♂️","aliases":["genie_man"]},{"emoji":"🧞‍♀️","aliases":["genie_woman"]},{"emoji":"🇬🇪","aliases":["georgia"]},{"emoji":"🇬🇭","aliases":["ghana"]},{"emoji":"👻","aliases":["ghost"]},{"emoji":"🇬🇮","aliases":["gibraltar"]},{"emoji":"🎁","aliases":["gift"]},{"emoji":"💝","aliases":["gift_heart"]},{"emoji":"🫚","aliases":["ginger_root"]},{"emoji":"🦒","aliases":["giraffe"]},{"emoji":"👧","aliases":["girl"]},{"emoji":"🌐","aliases":["globe_with_meridians"]},{"emoji":"🧤","aliases":["gloves"]},{"emoji":"🥅","aliases":["goal_net"]},{"emoji":"🐐","aliases":["goat"]},{"emoji":"🥽","aliases":["goggles"]},{"emoji":"⛳","aliases":["golf"]},{"emoji":"🏌️","aliases":["golfing"]},{"emoji":"🏌️‍♂️","aliases":["golfing_man"]},{"emoji":"🏌️‍♀️","aliases":["golfing_woman"]},{"emoji":"🪿","aliases":["goose"]},{"emoji":"🦍","aliases":["gorilla"]},{"emoji":"🍇","aliases":["grapes"]},{"emoji":"🇬🇷","aliases":["greece"]},{"emoji":"🍏","aliases":["green_apple"]},{"emoji":"📗","aliases":["green_book"]},{"emoji":"🟢","aliases":["green_circle"]},{"emoji":"💚","aliases":["green_heart"]},{"emoji":"🥗","aliases":["green_salad"]},{"emoji":"🟩","aliases":["green_square"]},{"emoji":"🇬🇱","aliases":["greenland"]},{"emoji":"🇬🇩","aliases":["grenada"]},{"emoji":"❕","aliases":["grey_exclamation"]},{"emoji":"🩶","aliases":["grey_heart"]},{"emoji":"❔","aliases":["grey_question"]},{"emoji":"😬","aliases":["grimacing"]},{"emoji":"😁","aliases":["grin"]},{"emoji":"😀","aliases":["grinning"]},{"emoji":"🇬🇵","aliases":["guadeloupe"]},{"emoji":"🇬🇺","aliases":["guam"]},{"emoji":"💂","aliases":["guard"]},{"emoji":"💂‍♂️","aliases":["guardsman"]},{"emoji":"💂‍♀️","aliases":["guardswoman"]},{"emoji":"🇬🇹","aliases":["guatemala"]},{"emoji":"🇬🇬","aliases":["guernsey"]},{"emoji":"🦮","aliases":["guide_dog"]},{"emoji":"🇬🇳","aliases":["guinea"]},{"emoji":"🇬🇼","aliases":["guinea_bissau"]},{"emoji":"🎸","aliases":["guitar"]},{"emoji":"🔫","aliases":["gun"]},{"emoji":"🇬🇾","aliases":["guyana"]},{"emoji":"🪮","aliases":["hair_pick"]},{"emoji":"💇","aliases":["haircut"]},{"emoji":"💇‍♂️","aliases":["haircut_man"]},{"emoji":"💇‍♀️","aliases":["haircut_woman"]},{"emoji":"🇭🇹","aliases":["haiti"]},{"emoji":"🍔","aliases":["hamburger"]},{"emoji":"🔨","aliases":["hammer"]},{"emoji":"⚒️","aliases":["hammer_and_pick"]},{"emoji":"🛠️","aliases":["hammer_and_wrench"]},{"emoji":"🪬","aliases":["hamsa"]},{"emoji":"🐹","aliases":["hamster"]},{"emoji":"✋","aliases":["hand","raised_hand"]},{"emoji":"🤭","aliases":["hand_over_mouth"]},{"emoji":"🫰","aliases":["hand_with_index_finger_and_thumb_crossed"]},{"emoji":"👜","aliases":["handbag"]},{"emoji":"🤾","aliases":["handball_person"]},{"emoji":"🤝","aliases":["handshake"]},{"emoji":"💩","aliases":["hankey","poop","shit"]},{"emoji":"#️⃣","aliases":["hash"]},{"emoji":"🐥","aliases":["hatched_chick"]},{"emoji":"🐣","aliases":["hatching_chick"]},{"emoji":"🎧","aliases":["headphones"]},{"emoji":"🪦","aliases":["headstone"]},{"emoji":"🧑‍⚕️","aliases":["health_worker"]},{"emoji":"🙉","aliases":["hear_no_evil"]},{"emoji":"🇭🇲","aliases":["heard_mcdonald_islands"]},{"emoji":"❤️","aliases":["heart"]},{"emoji":"💟","aliases":["heart_decoration"]},{"emoji":"😍","aliases":["heart_eyes"]},{"emoji":"😻","aliases":["heart_eyes_cat"]},{"emoji":"🫶","aliases":["heart_hands"]},{"emoji":"❤️‍🔥","aliases":["heart_on_fire"]},{"emoji":"💓","aliases":["heartbeat"]},{"emoji":"💗","aliases":["heartpulse"]},{"emoji":"♥️","aliases":["hearts"]},{"emoji":"✔️","aliases":["heavy_check_mark"]},{"emoji":"➗","aliases":["heavy_division_sign"]},{"emoji":"💲","aliases":["heavy_dollar_sign"]},{"emoji":"🟰","aliases":["heavy_equals_sign"]},{"emoji":"❣️","aliases":["heavy_heart_exclamation"]},{"emoji":"➖","aliases":["heavy_minus_sign"]},{"emoji":"✖️","aliases":["heavy_multiplication_x"]},{"emoji":"➕","aliases":["heavy_plus_sign"]},{"emoji":"🦔","aliases":["hedgehog"]},{"emoji":"🚁","aliases":["helicopter"]},{"emoji":"🌿","aliases":["herb"]},{"emoji":"🌺","aliases":["hibiscus"]},{"emoji":"🔆","aliases":["high_brightness"]},{"emoji":"👠","aliases":["high_heel"]},{"emoji":"🥾","aliases":["hiking_boot"]},{"emoji":"🛕","aliases":["hindu_temple"]},{"emoji":"🦛","aliases":["hippopotamus"]},{"emoji":"🔪","aliases":["hocho","knife"]},{"emoji":"🕳️","aliases":["hole"]},{"emoji":"🇭🇳","aliases":["honduras"]},{"emoji":"🍯","aliases":["honey_pot"]},{"emoji":"🇭🇰","aliases":["hong_kong"]},{"emoji":"🪝","aliases":["hook"]},{"emoji":"🐴","aliases":["horse"]},{"emoji":"🏇","aliases":["horse_racing"]},{"emoji":"🏥","aliases":["hospital"]},{"emoji":"🥵","aliases":["hot_face"]},{"emoji":"🌶️","aliases":["hot_pepper"]},{"emoji":"🌭","aliases":["hotdog"]},{"emoji":"🏨","aliases":["hotel"]},{"emoji":"♨️","aliases":["hotsprings"]},{"emoji":"⌛","aliases":["hourglass"]},{"emoji":"⏳","aliases":["hourglass_flowing_sand"]},{"emoji":"🏠","aliases":["house"]},{"emoji":"🏡","aliases":["house_with_garden"]},{"emoji":"🏘️","aliases":["houses"]},{"emoji":"🤗","aliases":["hugs"]},{"emoji":"🇭🇺","aliases":["hungary"]},{"emoji":"😯","aliases":["hushed"]},{"emoji":"🛖","aliases":["hut"]},{"emoji":"🪻","aliases":["hyacinth"]},{"emoji":"🍨","aliases":["ice_cream"]},{"emoji":"🧊","aliases":["ice_cube"]},{"emoji":"🏒","aliases":["ice_hockey"]},{"emoji":"⛸️","aliases":["ice_skate"]},{"emoji":"🍦","aliases":["icecream"]},{"emoji":"🇮🇸","aliases":["iceland"]},{"emoji":"🆔","aliases":["id"]},{"emoji":"🪪","aliases":["identification_card"]},{"emoji":"🉐","aliases":["ideograph_advantage"]},{"emoji":"👿","aliases":["imp"]},{"emoji":"📥","aliases":["inbox_tray"]},{"emoji":"📨","aliases":["incoming_envelope"]},{"emoji":"🫵","aliases":["index_pointing_at_the_viewer"]},{"emoji":"🇮🇳","aliases":["india"]},{"emoji":"🇮🇩","aliases":["indonesia"]},{"emoji":"♾️","aliases":["infinity"]},{"emoji":"ℹ️","aliases":["information_source"]},{"emoji":"😇","aliases":["innocent"]},{"emoji":"⁉️","aliases":["interrobang"]},{"emoji":"📱","aliases":["iphone"]},{"emoji":"🇮🇷","aliases":["iran"]},{"emoji":"🇮🇶","aliases":["iraq"]},{"emoji":"🇮🇪","aliases":["ireland"]},{"emoji":"🇮🇲","aliases":["isle_of_man"]},{"emoji":"🇮🇱","aliases":["israel"]},{"emoji":"🇮🇹","aliases":["it"]},{"emoji":"🏮","aliases":["izakaya_lantern","lantern"]},{"emoji":"🎃","aliases":["jack_o_lantern"]},{"emoji":"🇯🇲","aliases":["jamaica"]},{"emoji":"🗾","aliases":["japan"]},{"emoji":"🏯","aliases":["japanese_castle"]},{"emoji":"👺","aliases":["japanese_goblin"]},{"emoji":"👹","aliases":["japanese_ogre"]},{"emoji":"🫙","aliases":["jar"]},{"emoji":"👖","aliases":["jeans"]},{"emoji":"🪼","aliases":["jellyfish"]},{"emoji":"🇯🇪","aliases":["jersey"]},{"emoji":"🧩","aliases":["jigsaw"]},{"emoji":"🇯🇴","aliases":["jordan"]},{"emoji":"😂","aliases":["joy"]},{"emoji":"😹","aliases":["joy_cat"]},{"emoji":"🕹️","aliases":["joystick"]},{"emoji":"🇯🇵","aliases":["jp"]},{"emoji":"🧑‍⚖️","aliases":["judge"]},{"emoji":"🤹","aliases":["juggling_person"]},{"emoji":"🕋","aliases":["kaaba"]},{"emoji":"🦘","aliases":["kangaroo"]},{"emoji":"🇰🇿","aliases":["kazakhstan"]},{"emoji":"🇰🇪","aliases":["kenya"]},{"emoji":"🔑","aliases":["key"]},{"emoji":"⌨️","aliases":["keyboard"]},{"emoji":"🔟","aliases":["keycap_ten"]},{"emoji":"🪯","aliases":["khanda"]},{"emoji":"🛴","aliases":["kick_scooter"]},{"emoji":"👘","aliases":["kimono"]},{"emoji":"🇰🇮","aliases":["kiribati"]},{"emoji":"💋","aliases":["kiss"]},{"emoji":"😗","aliases":["kissing"]},{"emoji":"😽","aliases":["kissing_cat"]},{"emoji":"😚","aliases":["kissing_closed_eyes"]},{"emoji":"😘","aliases":["kissing_heart"]},{"emoji":"😙","aliases":["kissing_smiling_eyes"]},{"emoji":"🪁","aliases":["kite"]},{"emoji":"🥝","aliases":["kiwi_fruit"]},{"emoji":"🧎‍♂️","aliases":["kneeling_man"]},{"emoji":"🧎","aliases":["kneeling_person"]},{"emoji":"🧎‍♀️","aliases":["kneeling_woman"]},{"emoji":"🪢","aliases":["knot"]},{"emoji":"🐨","aliases":["koala"]},{"emoji":"🈁","aliases":["koko"]},{"emoji":"🇽🇰","aliases":["kosovo"]},{"emoji":"🇰🇷","aliases":["kr"]},{"emoji":"🇰🇼","aliases":["kuwait"]},{"emoji":"🇰🇬","aliases":["kyrgyzstan"]},{"emoji":"🥼","aliases":["lab_coat"]},{"emoji":"🏷️","aliases":["label"]},{"emoji":"🥍","aliases":["lacrosse"]},{"emoji":"🪜","aliases":["ladder"]},{"emoji":"🐞","aliases":["lady_beetle"]},{"emoji":"🇱🇦","aliases":["laos"]},{"emoji":"🔵","aliases":["large_blue_circle"]},{"emoji":"🔷","aliases":["large_blue_diamond"]},{"emoji":"🔶","aliases":["large_orange_diamond"]},{"emoji":"🌗","aliases":["last_quarter_moon"]},{"emoji":"🌜","aliases":["last_quarter_moon_with_face"]},{"emoji":"✝️","aliases":["latin_cross"]},{"emoji":"🇱🇻","aliases":["latvia"]},{"emoji":"😆","aliases":["laughing","satisfied","laugh"]},{"emoji":"🥬","aliases":["leafy_green"]},{"emoji":"🍃","aliases":["leaves"]},{"emoji":"🇱🇧","aliases":["lebanon"]},{"emoji":"📒","aliases":["ledger"]},{"emoji":"🛅","aliases":["left_luggage"]},{"emoji":"↔️","aliases":["left_right_arrow"]},{"emoji":"🗨️","aliases":["left_speech_bubble"]},{"emoji":"↩️","aliases":["leftwards_arrow_with_hook"]},{"emoji":"🫲","aliases":["leftwards_hand"]},{"emoji":"🫷","aliases":["leftwards_pushing_hand"]},{"emoji":"🦵","aliases":["leg"]},{"emoji":"🍋","aliases":["lemon"]},{"emoji":"♌","aliases":["leo"]},{"emoji":"🐆","aliases":["leopard"]},{"emoji":"🇱🇸","aliases":["lesotho"]},{"emoji":"🎚️","aliases":["level_slider"]},{"emoji":"🇱🇷","aliases":["liberia"]},{"emoji":"♎","aliases":["libra"]},{"emoji":"🇱🇾","aliases":["libya"]},{"emoji":"🇱🇮","aliases":["liechtenstein"]},{"emoji":"🩵","aliases":["light_blue_heart"]},{"emoji":"🚈","aliases":["light_rail"]},{"emoji":"🔗","aliases":["link"]},{"emoji":"🦁","aliases":["lion"]},{"emoji":"👄","aliases":["lips"]},{"emoji":"💄","aliases":["lipstick"]},{"emoji":"🇱🇹","aliases":["lithuania"]},{"emoji":"🦎","aliases":["lizard"]},{"emoji":"🦙","aliases":["llama"]},{"emoji":"🦞","aliases":["lobster"]},{"emoji":"🔒","aliases":["lock"]},{"emoji":"🔏","aliases":["lock_with_ink_pen"]},{"emoji":"🍭","aliases":["lollipop"]},{"emoji":"🪘","aliases":["long_drum"]},{"emoji":"➿","aliases":["loop"]},{"emoji":"🧴","aliases":["lotion_bottle"]},{"emoji":"🪷","aliases":["lotus"]},{"emoji":"🧘","aliases":["lotus_position"]},{"emoji":"🧘‍♂️","aliases":["lotus_position_man"]},{"emoji":"🧘‍♀️","aliases":["lotus_position_woman"]},{"emoji":"🔊","aliases":["loud_sound"]},{"emoji":"📢","aliases":["loudspeaker"]},{"emoji":"🏩","aliases":["love_hotel"]},{"emoji":"💌","aliases":["love_letter"]},{"emoji":"🤟","aliases":["love_you_gesture"]},{"emoji":"🪫","aliases":["low_battery"]},{"emoji":"🔅","aliases":["low_brightness"]},{"emoji":"🧳","aliases":["luggage"]},{"emoji":"🫁","aliases":["lungs"]},{"emoji":"🇱🇺","aliases":["luxembourg"]},{"emoji":"🤥","aliases":["lying_face"]},{"emoji":"Ⓜ️","aliases":["m"]},{"emoji":"🇲🇴","aliases":["macau"]},{"emoji":"🇲🇰","aliases":["macedonia"]},{"emoji":"🇲🇬","aliases":["madagascar"]},{"emoji":"🔍","aliases":["mag"]},{"emoji":"🔎","aliases":["mag_right"]},{"emoji":"🧙","aliases":["mage"]},{"emoji":"🧙‍♂️","aliases":["mage_man"]},{"emoji":"🧙‍♀️","aliases":["mage_woman"]},{"emoji":"🪄","aliases":["magic_wand"]},{"emoji":"🧲","aliases":["magnet"]},{"emoji":"🀄","aliases":["mahjong"]},{"emoji":"📫","aliases":["mailbox"]},{"emoji":"📪","aliases":["mailbox_closed"]},{"emoji":"📬","aliases":["mailbox_with_mail"]},{"emoji":"📭","aliases":["mailbox_with_no_mail"]},{"emoji":"🇲🇼","aliases":["malawi"]},{"emoji":"🇲🇾","aliases":["malaysia"]},{"emoji":"🇲🇻","aliases":["maldives"]},{"emoji":"🕵️‍♂️","aliases":["male_detective"]},{"emoji":"♂️","aliases":["male_sign"]},{"emoji":"🇲🇱","aliases":["mali"]},{"emoji":"🇲🇹","aliases":["malta"]},{"emoji":"🦣","aliases":["mammoth"]},{"emoji":"👨","aliases":["man"]},{"emoji":"👨‍🎨","aliases":["man_artist"]},{"emoji":"👨‍🚀","aliases":["man_astronaut"]},{"emoji":"🧔‍♂️","aliases":["man_beard"]},{"emoji":"🤸‍♂️","aliases":["man_cartwheeling"]},{"emoji":"👨‍🍳","aliases":["man_cook"]},{"emoji":"🕺","aliases":["man_dancing"]},{"emoji":"🤦‍♂️","aliases":["man_facepalming"]},{"emoji":"👨‍🏭","aliases":["man_factory_worker"]},{"emoji":"👨‍🌾","aliases":["man_farmer"]},{"emoji":"👨‍🍼","aliases":["man_feeding_baby"]},{"emoji":"👨‍🚒","aliases":["man_firefighter"]},{"emoji":"👨‍⚕️","aliases":["man_health_worker"]},{"emoji":"👨‍🦽","aliases":["man_in_manual_wheelchair"]},{"emoji":"👨‍🦼","aliases":["man_in_motorized_wheelchair"]},{"emoji":"🤵‍♂️","aliases":["man_in_tuxedo"]},{"emoji":"👨‍⚖️","aliases":["man_judge"]},{"emoji":"🤹‍♂️","aliases":["man_juggling"]},{"emoji":"👨‍🔧","aliases":["man_mechanic"]},{"emoji":"👨‍💼","aliases":["man_office_worker"]},{"emoji":"👨‍✈️","aliases":["man_pilot"]},{"emoji":"🤾‍♂️","aliases":["man_playing_handball"]},{"emoji":"🤽‍♂️","aliases":["man_playing_water_polo"]},{"emoji":"👨‍🔬","aliases":["man_scientist"]},{"emoji":"🤷‍♂️","aliases":["man_shrugging"]},{"emoji":"👨‍🎤","aliases":["man_singer"]},{"emoji":"👨‍🎓","aliases":["man_student"]},{"emoji":"👨‍🏫","aliases":["man_teacher"]},{"emoji":"👨‍💻","aliases":["man_technologist"]},{"emoji":"👲","aliases":["man_with_gua_pi_mao"]},{"emoji":"👨‍🦯","aliases":["man_with_probing_cane"]},{"emoji":"👳‍♂️","aliases":["man_with_turban"]},{"emoji":"👰‍♂️","aliases":["man_with_veil"]},{"emoji":"🥭","aliases":["mango"]},{"emoji":"👞","aliases":["mans_shoe","shoe"]},{"emoji":"🕰️","aliases":["mantelpiece_clock"]},{"emoji":"🦽","aliases":["manual_wheelchair"]},{"emoji":"🍁","aliases":["maple_leaf"]},{"emoji":"🪇","aliases":["maracas"]},{"emoji":"🇲🇭","aliases":["marshall_islands"]},{"emoji":"🥋","aliases":["martial_arts_uniform"]},{"emoji":"🇲🇶","aliases":["martinique"]},{"emoji":"😷","aliases":["mask"]},{"emoji":"💆","aliases":["massage"]},{"emoji":"💆‍♂️","aliases":["massage_man"]},{"emoji":"💆‍♀️","aliases":["massage_woman"]},{"emoji":"🧉","aliases":["mate"]},{"emoji":"🇲🇷","aliases":["mauritania"]},{"emoji":"🇲🇺","aliases":["mauritius"]},{"emoji":"🇾🇹","aliases":["mayotte"]},{"emoji":"🍖","aliases":["meat_on_bone"]},{"emoji":"🧑‍🔧","aliases":["mechanic"]},{"emoji":"🦾","aliases":["mechanical_arm"]},{"emoji":"🦿","aliases":["mechanical_leg"]},{"emoji":"🎖️","aliases":["medal_military"]},{"emoji":"🏅","aliases":["medal_sports"]},{"emoji":"⚕️","aliases":["medical_symbol"]},{"emoji":"📣","aliases":["mega"]},{"emoji":"🍈","aliases":["melon"]},{"emoji":"🫠","aliases":["melting_face"]},{"emoji":"📝","aliases":["memo","pencil"]},{"emoji":"🤼‍♂️","aliases":["men_wrestling"]},{"emoji":"❤️‍🩹","aliases":["mending_heart"]},{"emoji":"🕎","aliases":["menorah"]},{"emoji":"🚹","aliases":["mens"]},{"emoji":"🧜‍♀️","aliases":["mermaid"]},{"emoji":"🧜‍♂️","aliases":["merman"]},{"emoji":"🧜","aliases":["merperson"]},{"emoji":"🤘","aliases":["metal"]},{"emoji":"🚇","aliases":["metro"]},{"emoji":"🇲🇽","aliases":["mexico"]},{"emoji":"🦠","aliases":["microbe"]},{"emoji":"🇫🇲","aliases":["micronesia"]},{"emoji":"🎤","aliases":["microphone"]},{"emoji":"🔬","aliases":["microscope"]},{"emoji":"🖕","aliases":["middle_finger","fu"]},{"emoji":"🪖","aliases":["military_helmet"]},{"emoji":"🥛","aliases":["milk_glass"]},{"emoji":"🌌","aliases":["milky_way"]},{"emoji":"🚐","aliases":["minibus"]},{"emoji":"💽","aliases":["minidisc"]},{"emoji":"🪞","aliases":["mirror"]},{"emoji":"🪩","aliases":["mirror_ball"]},{"emoji":"📴","aliases":["mobile_phone_off"]},{"emoji":"🇲🇩","aliases":["moldova"]},{"emoji":"🇲🇨","aliases":["monaco"]},{"emoji":"🤑","aliases":["money_mouth_face"]},{"emoji":"💸","aliases":["money_with_wings"]},{"emoji":"💰","aliases":["moneybag"]},{"emoji":"🇲🇳","aliases":["mongolia"]},{"emoji":"🐒","aliases":["monkey"]},{"emoji":"🐵","aliases":["monkey_face"]},{"emoji":"🧐","aliases":["monocle_face"]},{"emoji":"🚝","aliases":["monorail"]},{"emoji":"🇲🇪","aliases":["montenegro"]},{"emoji":"🇲🇸","aliases":["montserrat"]},{"emoji":"🌔","aliases":["moon","waxing_gibbous_moon"]},{"emoji":"🥮","aliases":["moon_cake"]},{"emoji":"🫎","aliases":["moose"]},{"emoji":"🇲🇦","aliases":["morocco"]},{"emoji":"🎓","aliases":["mortar_board"]},{"emoji":"🕌","aliases":["mosque"]},{"emoji":"🦟","aliases":["mosquito"]},{"emoji":"🛥️","aliases":["motor_boat"]},{"emoji":"🛵","aliases":["motor_scooter"]},{"emoji":"🏍️","aliases":["motorcycle"]},{"emoji":"🦼","aliases":["motorized_wheelchair"]},{"emoji":"🛣️","aliases":["motorway"]},{"emoji":"🗻","aliases":["mount_fuji"]},{"emoji":"⛰️","aliases":["mountain"]},{"emoji":"🚵","aliases":["mountain_bicyclist"]},{"emoji":"🚵‍♂️","aliases":["mountain_biking_man"]},{"emoji":"🚵‍♀️","aliases":["mountain_biking_woman"]},{"emoji":"🚠","aliases":["mountain_cableway"]},{"emoji":"🚞","aliases":["mountain_railway"]},{"emoji":"🏔️","aliases":["mountain_snow"]},{"emoji":"🐭","aliases":["mouse"]},{"emoji":"🐁","aliases":["mouse2"]},{"emoji":"🪤","aliases":["mouse_trap"]},{"emoji":"🎥","aliases":["movie_camera"]},{"emoji":"🗿","aliases":["moyai"]},{"emoji":"🇲🇿","aliases":["mozambique"]},{"emoji":"🤶","aliases":["mrs_claus"]},{"emoji":"💪","aliases":["muscle"]},{"emoji":"🍄","aliases":["mushroom"]},{"emoji":"🎹","aliases":["musical_keyboard"]},{"emoji":"🎵","aliases":["musical_note"]},{"emoji":"🎼","aliases":["musical_score"]},{"emoji":"🔇","aliases":["mute"]},{"emoji":"🧑‍🎄","aliases":["mx_claus"]},{"emoji":"🇲🇲","aliases":["myanmar"]},{"emoji":"💅","aliases":["nail_care"]},{"emoji":"📛","aliases":["name_badge"]},{"emoji":"🇳🇦","aliases":["namibia"]},{"emoji":"🏞️","aliases":["national_park"]},{"emoji":"🇳🇷","aliases":["nauru"]},{"emoji":"🤢","aliases":["nauseated_face"]},{"emoji":"🧿","aliases":["nazar_amulet"]},{"emoji":"👔","aliases":["necktie"]},{"emoji":"❎","aliases":["negative_squared_cross_mark"]},{"emoji":"🇳🇵","aliases":["nepal"]},{"emoji":"🤓","aliases":["nerd_face"]},{"emoji":"🪺","aliases":["nest_with_eggs"]},{"emoji":"🪆","aliases":["nesting_dolls"]},{"emoji":"🇳🇱","aliases":["netherlands"]},{"emoji":"😐","aliases":["neutral_face"]},{"emoji":"🆕","aliases":["new"]},{"emoji":"🇳🇨","aliases":["new_caledonia"]},{"emoji":"🌑","aliases":["new_moon"]},{"emoji":"🌚","aliases":["new_moon_with_face"]},{"emoji":"🇳🇿","aliases":["new_zealand"]},{"emoji":"📰","aliases":["newspaper"]},{"emoji":"🗞️","aliases":["newspaper_roll"]},{"emoji":"⏭️","aliases":["next_track_button"]},{"emoji":"🆖","aliases":["ng"]},{"emoji":"🇳🇮","aliases":["nicaragua"]},{"emoji":"🇳🇪","aliases":["niger"]},{"emoji":"🇳🇬","aliases":["nigeria"]},{"emoji":"🌃","aliases":["night_with_stars"]},{"emoji":"9️⃣","aliases":["nine"]},{"emoji":"🥷","aliases":["ninja"]},{"emoji":"🇳🇺","aliases":["niue"]},{"emoji":"🔕","aliases":["no_bell"]},{"emoji":"🚳","aliases":["no_bicycles"]},{"emoji":"⛔","aliases":["no_entry"]},{"emoji":"🚫","aliases":["no_entry_sign"]},{"emoji":"🙅","aliases":["no_good"]},{"emoji":"🙅‍♂️","aliases":["no_good_man","ng_man"]},{"emoji":"🙅‍♀️","aliases":["no_good_woman","ng_woman"]},{"emoji":"📵","aliases":["no_mobile_phones"]},{"emoji":"😶","aliases":["no_mouth"]},{"emoji":"🚷","aliases":["no_pedestrians"]},{"emoji":"🚭","aliases":["no_smoking"]},{"emoji":"🚱","aliases":["non-potable_water"]},{"emoji":"🇳🇫","aliases":["norfolk_island"]},{"emoji":"🇰🇵","aliases":["north_korea"]},{"emoji":"🇲🇵","aliases":["northern_mariana_islands"]},{"emoji":"🇳🇴","aliases":["norway"]},{"emoji":"👃","aliases":["nose"]},{"emoji":"📓","aliases":["notebook"]},{"emoji":"📔","aliases":["notebook_with_decorative_cover"]},{"emoji":"🎶","aliases":["notes"]},{"emoji":"🔩","aliases":["nut_and_bolt"]},{"emoji":"⭕","aliases":["o"]},{"emoji":"🅾️","aliases":["o2"]},{"emoji":"🌊","aliases":["ocean"]},{"emoji":"🐙","aliases":["octopus"]},{"emoji":"🍢","aliases":["oden"]},{"emoji":"🏢","aliases":["office"]},{"emoji":"🧑‍💼","aliases":["office_worker"]},{"emoji":"🛢️","aliases":["oil_drum"]},{"emoji":"🆗","aliases":["ok"]},{"emoji":"👌","aliases":["ok_hand"]},{"emoji":"🙆‍♂️","aliases":["ok_man"]},{"emoji":"🙆","aliases":["ok_person"]},{"emoji":"🙆‍♀️","aliases":["ok_woman"]},{"emoji":"🗝️","aliases":["old_key"]},{"emoji":"🧓","aliases":["older_adult"]},{"emoji":"👴","aliases":["older_man"]},{"emoji":"👵","aliases":["older_woman"]},{"emoji":"🫒","aliases":["olive"]},{"emoji":"🕉️","aliases":["om"]},{"emoji":"🇴🇲","aliases":["oman"]},{"emoji":"🔛","aliases":["on"]},{"emoji":"🚘","aliases":["oncoming_automobile"]},{"emoji":"🚍","aliases":["oncoming_bus"]},{"emoji":"🚔","aliases":["oncoming_police_car"]},{"emoji":"🚖","aliases":["oncoming_taxi"]},{"emoji":"1️⃣","aliases":["one"]},{"emoji":"🩱","aliases":["one_piece_swimsuit"]},{"emoji":"🧅","aliases":["onion"]},{"emoji":"📂","aliases":["open_file_folder"]},{"emoji":"👐","aliases":["open_hands"]},{"emoji":"😮","aliases":["open_mouth"]},{"emoji":"☂️","aliases":["open_umbrella"]},{"emoji":"⛎","aliases":["ophiuchus"]},{"emoji":"📙","aliases":["orange_book"]},{"emoji":"🟠","aliases":["orange_circle"]},{"emoji":"🧡","aliases":["orange_heart"]},{"emoji":"🟧","aliases":["orange_square"]},{"emoji":"🦧","aliases":["orangutan"]},{"emoji":"☦️","aliases":["orthodox_cross"]},{"emoji":"🦦","aliases":["otter"]},{"emoji":"📤","aliases":["outbox_tray"]},{"emoji":"🦉","aliases":["owl"]},{"emoji":"🐂","aliases":["ox"]},{"emoji":"🦪","aliases":["oyster"]},{"emoji":"📦","aliases":["package"]},{"emoji":"📄","aliases":["page_facing_up"]},{"emoji":"📃","aliases":["page_with_curl"]},{"emoji":"📟","aliases":["pager"]},{"emoji":"🖌️","aliases":["paintbrush"]},{"emoji":"🇵🇰","aliases":["pakistan"]},{"emoji":"🇵🇼","aliases":["palau"]},{"emoji":"🇵🇸","aliases":["palestinian_territories"]},{"emoji":"🫳","aliases":["palm_down_hand"]},{"emoji":"🌴","aliases":["palm_tree"]},{"emoji":"🫴","aliases":["palm_up_hand"]},{"emoji":"🤲","aliases":["palms_up_together"]},{"emoji":"🇵🇦","aliases":["panama"]},{"emoji":"🥞","aliases":["pancakes"]},{"emoji":"🐼","aliases":["panda_face"]},{"emoji":"📎","aliases":["paperclip"]},{"emoji":"🖇️","aliases":["paperclips"]},{"emoji":"🇵🇬","aliases":["papua_new_guinea"]},{"emoji":"🪂","aliases":["parachute"]},{"emoji":"🇵🇾","aliases":["paraguay"]},{"emoji":"⛱️","aliases":["parasol_on_ground"]},{"emoji":"🅿️","aliases":["parking"]},{"emoji":"🦜","aliases":["parrot"]},{"emoji":"〽️","aliases":["part_alternation_mark"]},{"emoji":"⛅","aliases":["partly_sunny"]},{"emoji":"🥳","aliases":["partying_face"]},{"emoji":"🛳️","aliases":["passenger_ship"]},{"emoji":"🛂","aliases":["passport_control"]},{"emoji":"⏸️","aliases":["pause_button"]},{"emoji":"🫛","aliases":["pea_pod"]},{"emoji":"☮️","aliases":["peace_symbol"]},{"emoji":"🍑","aliases":["peach"]},{"emoji":"🦚","aliases":["peacock"]},{"emoji":"🥜","aliases":["peanuts"]},{"emoji":"🍐","aliases":["pear"]},{"emoji":"🖊️","aliases":["pen"]},{"emoji":"✏️","aliases":["pencil2"]},{"emoji":"🐧","aliases":["penguin"]},{"emoji":"😔","aliases":["pensive"]},{"emoji":"🧑‍🤝‍🧑","aliases":["people_holding_hands"]},{"emoji":"🫂","aliases":["people_hugging"]},{"emoji":"🎭","aliases":["performing_arts"]},{"emoji":"😣","aliases":["persevere"]},{"emoji":"🧑‍🦲","aliases":["person_bald"]},{"emoji":"🧑‍🦱","aliases":["person_curly_hair"]},{"emoji":"🧑‍🍼","aliases":["person_feeding_baby"]},{"emoji":"🤺","aliases":["person_fencing"]},{"emoji":"🧑‍🦽","aliases":["person_in_manual_wheelchair"]},{"emoji":"🧑‍🦼","aliases":["person_in_motorized_wheelchair"]},{"emoji":"🤵","aliases":["person_in_tuxedo"]},{"emoji":"🧑‍🦰","aliases":["person_red_hair"]},{"emoji":"🧑‍🦳","aliases":["person_white_hair"]},{"emoji":"🫅","aliases":["person_with_crown"]},{"emoji":"🧑‍🦯","aliases":["person_with_probing_cane"]},{"emoji":"👳","aliases":["person_with_turban"]},{"emoji":"👰","aliases":["person_with_veil"]},{"emoji":"🇵🇪","aliases":["peru"]},{"emoji":"🧫","aliases":["petri_dish"]},{"emoji":"🇵🇭","aliases":["philippines"]},{"emoji":"☎️","aliases":["phone","telephone"]},{"emoji":"⛏️","aliases":["pick"]},{"emoji":"🛻","aliases":["pickup_truck"]},{"emoji":"🥧","aliases":["pie"]},{"emoji":"🐷","aliases":["pig"]},{"emoji":"🐖","aliases":["pig2"]},{"emoji":"🐽","aliases":["pig_nose"]},{"emoji":"💊","aliases":["pill"]},{"emoji":"🧑‍✈️","aliases":["pilot"]},{"emoji":"🪅","aliases":["pinata"]},{"emoji":"🤌","aliases":["pinched_fingers"]},{"emoji":"🤏","aliases":["pinching_hand"]},{"emoji":"🍍","aliases":["pineapple"]},{"emoji":"🏓","aliases":["ping_pong"]},{"emoji":"🩷","aliases":["pink_heart"]},{"emoji":"🏴‍☠️","aliases":["pirate_flag"]},{"emoji":"♓","aliases":["pisces"]},{"emoji":"🇵🇳","aliases":["pitcairn_islands"]},{"emoji":"🍕","aliases":["pizza"]},{"emoji":"🪧","aliases":["placard"]},{"emoji":"🛐","aliases":["place_of_worship"]},{"emoji":"🍽️","aliases":["plate_with_cutlery"]},{"emoji":"⏯️","aliases":["play_or_pause_button"]},{"emoji":"🛝","aliases":["playground_slide"]},{"emoji":"🥺","aliases":["pleading_face"]},{"emoji":"🪠","aliases":["plunger"]},{"emoji":"👇","aliases":["point_down"]},{"emoji":"👈","aliases":["point_left"]},{"emoji":"👉","aliases":["point_right"]},{"emoji":"☝️","aliases":["point_up"]},{"emoji":"👆","aliases":["point_up_2"]},{"emoji":"🇵🇱","aliases":["poland"]},{"emoji":"🐻‍❄️","aliases":["polar_bear"]},{"emoji":"🚓","aliases":["police_car"]},{"emoji":"👮","aliases":["police_officer","cop"]},{"emoji":"👮‍♂️","aliases":["policeman"]},{"emoji":"👮‍♀️","aliases":["policewoman"]},{"emoji":"🐩","aliases":["poodle"]},{"emoji":"🍿","aliases":["popcorn"]},{"emoji":"🇵🇹","aliases":["portugal"]},{"emoji":"🏣","aliases":["post_office"]},{"emoji":"📯","aliases":["postal_horn"]},{"emoji":"📮","aliases":["postbox"]},{"emoji":"🚰","aliases":["potable_water"]},{"emoji":"🥔","aliases":["potato"]},{"emoji":"🪴","aliases":["potted_plant"]},{"emoji":"👝","aliases":["pouch"]},{"emoji":"🍗","aliases":["poultry_leg"]},{"emoji":"💷","aliases":["pound"]},{"emoji":"🫗","aliases":["pouring_liquid"]},{"emoji":"😾","aliases":["pouting_cat"]},{"emoji":"🙎","aliases":["pouting_face"]},{"emoji":"🙎‍♂️","aliases":["pouting_man"]},{"emoji":"🙎‍♀️","aliases":["pouting_woman"]},{"emoji":"🙏","aliases":["pray"]},{"emoji":"📿","aliases":["prayer_beads"]},{"emoji":"🫃","aliases":["pregnant_man"]},{"emoji":"🫄","aliases":["pregnant_person"]},{"emoji":"🤰","aliases":["pregnant_woman"]},{"emoji":"🥨","aliases":["pretzel"]},{"emoji":"⏮️","aliases":["previous_track_button"]},{"emoji":"🤴","aliases":["prince"]},{"emoji":"👸","aliases":["princess"]},{"emoji":"🖨️","aliases":["printer"]},{"emoji":"🦯","aliases":["probing_cane"]},{"emoji":"🇵🇷","aliases":["puerto_rico"]},{"emoji":"🟣","aliases":["purple_circle"]},{"emoji":"💜","aliases":["purple_heart"]},{"emoji":"🟪","aliases":["purple_square"]},{"emoji":"👛","aliases":["purse"]},{"emoji":"📌","aliases":["pushpin"]},{"emoji":"🚮","aliases":["put_litter_in_its_place"]},{"emoji":"🇶🇦","aliases":["qatar"]},{"emoji":"❓","aliases":["question"]},{"emoji":"🐰","aliases":["rabbit"]},{"emoji":"🐇","aliases":["rabbit2"]},{"emoji":"🦝","aliases":["raccoon"]},{"emoji":"🐎","aliases":["racehorse"]},{"emoji":"🏎️","aliases":["racing_car"]},{"emoji":"📻","aliases":["radio"]},{"emoji":"🔘","aliases":["radio_button"]},{"emoji":"☢️","aliases":["radioactive"]},{"emoji":"😡","aliases":["rage","pout"]},{"emoji":"🚃","aliases":["railway_car"]},{"emoji":"🛤️","aliases":["railway_track"]},{"emoji":"🌈","aliases":["rainbow"]},{"emoji":"🏳️‍🌈","aliases":["rainbow_flag"]},{"emoji":"🤚","aliases":["raised_back_of_hand"]},{"emoji":"🤨","aliases":["raised_eyebrow"]},{"emoji":"🖐️","aliases":["raised_hand_with_fingers_splayed"]},{"emoji":"🙌","aliases":["raised_hands"]},{"emoji":"🙋","aliases":["raising_hand"]},{"emoji":"🙋‍♂️","aliases":["raising_hand_man"]},{"emoji":"🙋‍♀️","aliases":["raising_hand_woman"]},{"emoji":"🐏","aliases":["ram"]},{"emoji":"🍜","aliases":["ramen"]},{"emoji":"🐀","aliases":["rat"]},{"emoji":"🪒","aliases":["razor"]},{"emoji":"🧾","aliases":["receipt"]},{"emoji":"⏺️","aliases":["record_button"]},{"emoji":"♻️","aliases":["recycle"]},{"emoji":"🔴","aliases":["red_circle"]},{"emoji":"🧧","aliases":["red_envelope"]},{"emoji":"👨‍🦰","aliases":["red_haired_man"]},{"emoji":"👩‍🦰","aliases":["red_haired_woman"]},{"emoji":"🟥","aliases":["red_square"]},{"emoji":"®️","aliases":["registered"]},{"emoji":"☺️","aliases":["relaxed"]},{"emoji":"😌","aliases":["relieved"]},{"emoji":"🎗️","aliases":["reminder_ribbon"]},{"emoji":"🔁","aliases":["repeat"]},{"emoji":"🔂","aliases":["repeat_one"]},{"emoji":"⛑️","aliases":["rescue_worker_helmet"]},{"emoji":"🚻","aliases":["restroom"]},{"emoji":"🇷🇪","aliases":["reunion"]},{"emoji":"💞","aliases":["revolving_hearts"]},{"emoji":"⏪","aliases":["rewind"]},{"emoji":"🦏","aliases":["rhinoceros"]},{"emoji":"🎀","aliases":["ribbon"]},{"emoji":"🍚","aliases":["rice"]},{"emoji":"🍙","aliases":["rice_ball"]},{"emoji":"🍘","aliases":["rice_cracker"]},{"emoji":"🎑","aliases":["rice_scene"]},{"emoji":"🗯️","aliases":["right_anger_bubble"]},{"emoji":"🫱","aliases":["rightwards_hand"]},{"emoji":"🫸","aliases":["rightwards_pushing_hand"]},{"emoji":"💍","aliases":["ring"]},{"emoji":"🛟","aliases":["ring_buoy"]},{"emoji":"🪐","aliases":["ringed_planet"]},{"emoji":"🤖","aliases":["robot"]},{"emoji":"🪨","aliases":["rock"]},{"emoji":"🚀","aliases":["rocket"]},{"emoji":"🤣","aliases":["rofl"]},{"emoji":"🙄","aliases":["roll_eyes"]},{"emoji":"🧻","aliases":["roll_of_paper"]},{"emoji":"🎢","aliases":["roller_coaster"]},{"emoji":"🛼","aliases":["roller_skate"]},{"emoji":"🇷🇴","aliases":["romania"]},{"emoji":"🐓","aliases":["rooster"]},{"emoji":"🌹","aliases":["rose"]},{"emoji":"🏵️","aliases":["rosette"]},{"emoji":"🚨","aliases":["rotating_light"]},{"emoji":"📍","aliases":["round_pushpin"]},{"emoji":"🚣","aliases":["rowboat"]},{"emoji":"🚣‍♂️","aliases":["rowing_man"]},{"emoji":"🚣‍♀️","aliases":["rowing_woman"]},{"emoji":"🇷🇺","aliases":["ru"]},{"emoji":"🏉","aliases":["rugby_football"]},{"emoji":"🏃","aliases":["runner","running"]},{"emoji":"🏃‍♂️","aliases":["running_man"]},{"emoji":"🎽","aliases":["running_shirt_with_sash"]},{"emoji":"🏃‍♀️","aliases":["running_woman"]},{"emoji":"🇷🇼","aliases":["rwanda"]},{"emoji":"🈂️","aliases":["sa"]},{"emoji":"🧷","aliases":["safety_pin"]},{"emoji":"🦺","aliases":["safety_vest"]},{"emoji":"♐","aliases":["sagittarius"]},{"emoji":"🍶","aliases":["sake"]},{"emoji":"🧂","aliases":["salt"]},{"emoji":"🫡","aliases":["saluting_face"]},{"emoji":"🇼🇸","aliases":["samoa"]},{"emoji":"🇸🇲","aliases":["san_marino"]},{"emoji":"👡","aliases":["sandal"]},{"emoji":"🥪","aliases":["sandwich"]},{"emoji":"🎅","aliases":["santa"]},{"emoji":"🇸🇹","aliases":["sao_tome_principe"]},{"emoji":"🥻","aliases":["sari"]},{"emoji":"📡","aliases":["satellite"]},{"emoji":"🇸🇦","aliases":["saudi_arabia"]},{"emoji":"🧖‍♂️","aliases":["sauna_man"]},{"emoji":"🧖","aliases":["sauna_person"]},{"emoji":"🧖‍♀️","aliases":["sauna_woman"]},{"emoji":"🦕","aliases":["sauropod"]},{"emoji":"🎷","aliases":["saxophone"]},{"emoji":"🧣","aliases":["scarf"]},{"emoji":"🏫","aliases":["school"]},{"emoji":"🎒","aliases":["school_satchel"]},{"emoji":"🧑‍🔬","aliases":["scientist"]},{"emoji":"✂️","aliases":["scissors"]},{"emoji":"🦂","aliases":["scorpion"]},{"emoji":"♏","aliases":["scorpius"]},{"emoji":"🏴󠁧󠁢󠁳󠁣󠁴󠁿","aliases":["scotland"]},{"emoji":"😱","aliases":["scream"]},{"emoji":"🙀","aliases":["scream_cat"]},{"emoji":"🪛","aliases":["screwdriver"]},{"emoji":"📜","aliases":["scroll"]},{"emoji":"🦭","aliases":["seal"]},{"emoji":"💺","aliases":["seat"]},{"emoji":"㊙️","aliases":["secret"]},{"emoji":"🙈","aliases":["see_no_evil"]},{"emoji":"🌱","aliases":["seedling"]},{"emoji":"🤳","aliases":["selfie"]},{"emoji":"🇸🇳","aliases":["senegal"]},{"emoji":"🇷🇸","aliases":["serbia"]},{"emoji":"🐕‍🦺","aliases":["service_dog"]},{"emoji":"7️⃣","aliases":["seven"]},{"emoji":"🪡","aliases":["sewing_needle"]},{"emoji":"🇸🇨","aliases":["seychelles"]},{"emoji":"🫨","aliases":["shaking_face"]},{"emoji":"🥘","aliases":["shallow_pan_of_food"]},{"emoji":"☘️","aliases":["shamrock"]},{"emoji":"🦈","aliases":["shark"]},{"emoji":"🍧","aliases":["shaved_ice"]},{"emoji":"🐑","aliases":["sheep"]},{"emoji":"🐚","aliases":["shell"]},{"emoji":"🛡️","aliases":["shield"]},{"emoji":"⛩️","aliases":["shinto_shrine"]},{"emoji":"🚢","aliases":["ship"]},{"emoji":"👕","aliases":["shirt","tshirt"]},{"emoji":"🛍️","aliases":["shopping"]},{"emoji":"🛒","aliases":["shopping_cart"]},{"emoji":"🩳","aliases":["shorts"]},{"emoji":"🚿","aliases":["shower"]},{"emoji":"🦐","aliases":["shrimp"]},{"emoji":"🤷","aliases":["shrug"]},{"emoji":"🤫","aliases":["shushing_face"]},{"emoji":"🇸🇱","aliases":["sierra_leone"]},{"emoji":"📶","aliases":["signal_strength"]},{"emoji":"🇸🇬","aliases":["singapore"]},{"emoji":"🧑‍🎤","aliases":["singer"]},{"emoji":"🇸🇽","aliases":["sint_maarten"]},{"emoji":"6️⃣","aliases":["six"]},{"emoji":"🔯","aliases":["six_pointed_star"]},{"emoji":"🛹","aliases":["skateboard"]},{"emoji":"🎿","aliases":["ski"]},{"emoji":"⛷️","aliases":["skier"]},{"emoji":"💀","aliases":["skull"]},{"emoji":"☠️","aliases":["skull_and_crossbones"]},{"emoji":"🦨","aliases":["skunk"]},{"emoji":"🛷","aliases":["sled"]},{"emoji":"😴","aliases":["sleeping"]},{"emoji":"🛌","aliases":["sleeping_bed"]},{"emoji":"😪","aliases":["sleepy"]},{"emoji":"🙁","aliases":["slightly_frowning_face"]},{"emoji":"🙂","aliases":["slightly_smiling_face"]},{"emoji":"🎰","aliases":["slot_machine"]},{"emoji":"🦥","aliases":["sloth"]},{"emoji":"🇸🇰","aliases":["slovakia"]},{"emoji":"🇸🇮","aliases":["slovenia"]},{"emoji":"🛩️","aliases":["small_airplane"]},{"emoji":"🔹","aliases":["small_blue_diamond"]},{"emoji":"🔸","aliases":["small_orange_diamond"]},{"emoji":"🔺","aliases":["small_red_triangle"]},{"emoji":"🔻","aliases":["small_red_triangle_down"]},{"emoji":"😄","aliases":["smile"]},{"emoji":"😸","aliases":["smile_cat"]},{"emoji":"😃","aliases":["smiley"]},{"emoji":"😺","aliases":["smiley_cat"]},{"emoji":"🥲","aliases":["smiling_face_with_tear"]},{"emoji":"🥰","aliases":["smiling_face_with_three_hearts"]},{"emoji":"😈","aliases":["smiling_imp"]},{"emoji":"😏","aliases":["smirk"]},{"emoji":"😼","aliases":["smirk_cat"]},{"emoji":"🚬","aliases":["smoking"]},{"emoji":"🐌","aliases":["snail"]},{"emoji":"🐍","aliases":["snake"]},{"emoji":"🤧","aliases":["sneezing_face"]},{"emoji":"🏂","aliases":["snowboarder"]},{"emoji":"❄️","aliases":["snowflake"]},{"emoji":"⛄","aliases":["snowman"]},{"emoji":"☃️","aliases":["snowman_with_snow"]},{"emoji":"🧼","aliases":["soap"]},{"emoji":"😭","aliases":["sob"]},{"emoji":"⚽","aliases":["soccer"]},{"emoji":"🧦","aliases":["socks"]},{"emoji":"🥎","aliases":["softball"]},{"emoji":"🇸🇧","aliases":["solomon_islands"]},{"emoji":"🇸🇴","aliases":["somalia"]},{"emoji":"🔜","aliases":["soon"]},{"emoji":"🆘","aliases":["sos"]},{"emoji":"🔉","aliases":["sound"]},{"emoji":"🇿🇦","aliases":["south_africa"]},{"emoji":"🇬🇸","aliases":["south_georgia_south_sandwich_islands"]},{"emoji":"🇸🇸","aliases":["south_sudan"]},{"emoji":"👾","aliases":["space_invader"]},{"emoji":"♠️","aliases":["spades"]},{"emoji":"🍝","aliases":["spaghetti"]},{"emoji":"❇️","aliases":["sparkle"]},{"emoji":"🎇","aliases":["sparkler"]},{"emoji":"✨","aliases":["sparkles"]},{"emoji":"💖","aliases":["sparkling_heart"]},{"emoji":"🙊","aliases":["speak_no_evil"]},{"emoji":"🔈","aliases":["speaker"]},{"emoji":"🗣️","aliases":["speaking_head"]},{"emoji":"💬","aliases":["speech_balloon"]},{"emoji":"🚤","aliases":["speedboat"]},{"emoji":"🕷️","aliases":["spider"]},{"emoji":"🕸️","aliases":["spider_web"]},{"emoji":"🗓️","aliases":["spiral_calendar"]},{"emoji":"🗒️","aliases":["spiral_notepad"]},{"emoji":"🧽","aliases":["sponge"]},{"emoji":"🥄","aliases":["spoon"]},{"emoji":"🦑","aliases":["squid"]},{"emoji":"🇱🇰","aliases":["sri_lanka"]},{"emoji":"🇧🇱","aliases":["st_barthelemy"]},{"emoji":"🇸🇭","aliases":["st_helena"]},{"emoji":"🇰🇳","aliases":["st_kitts_nevis"]},{"emoji":"🇱🇨","aliases":["st_lucia"]},{"emoji":"🇲🇫","aliases":["st_martin"]},{"emoji":"🇵🇲","aliases":["st_pierre_miquelon"]},{"emoji":"🇻🇨","aliases":["st_vincent_grenadines"]},{"emoji":"🏟️","aliases":["stadium"]},{"emoji":"🧍‍♂️","aliases":["standing_man"]},{"emoji":"🧍","aliases":["standing_person"]},{"emoji":"🧍‍♀️","aliases":["standing_woman"]},{"emoji":"⭐","aliases":["star"]},{"emoji":"🌟","aliases":["star2"]},{"emoji":"☪️","aliases":["star_and_crescent"]},{"emoji":"✡️","aliases":["star_of_david"]},{"emoji":"🤩","aliases":["star_struck"]},{"emoji":"🌠","aliases":["stars"]},{"emoji":"🚉","aliases":["station"]},{"emoji":"🗽","aliases":["statue_of_liberty"]},{"emoji":"🚂","aliases":["steam_locomotive"]},{"emoji":"🩺","aliases":["stethoscope"]},{"emoji":"🍲","aliases":["stew"]},{"emoji":"⏹️","aliases":["stop_button"]},{"emoji":"🛑","aliases":["stop_sign"]},{"emoji":"⏱️","aliases":["stopwatch"]},{"emoji":"📏","aliases":["straight_ruler"]},{"emoji":"🍓","aliases":["strawberry"]},{"emoji":"😛","aliases":["stuck_out_tongue"]},{"emoji":"😝","aliases":["stuck_out_tongue_closed_eyes"]},{"emoji":"😜","aliases":["stuck_out_tongue_winking_eye"]},{"emoji":"🧑‍🎓","aliases":["student"]},{"emoji":"🎙️","aliases":["studio_microphone"]},{"emoji":"🥙","aliases":["stuffed_flatbread"]},{"emoji":"🇸🇩","aliases":["sudan"]},{"emoji":"🌥️","aliases":["sun_behind_large_cloud"]},{"emoji":"🌦️","aliases":["sun_behind_rain_cloud"]},{"emoji":"🌤️","aliases":["sun_behind_small_cloud"]},{"emoji":"🌞","aliases":["sun_with_face"]},{"emoji":"🌻","aliases":["sunflower"]},{"emoji":"😎","aliases":["sunglasses"]},{"emoji":"☀️","aliases":["sunny"]},{"emoji":"🌅","aliases":["sunrise"]},{"emoji":"🌄","aliases":["sunrise_over_mountains"]},{"emoji":"🦸","aliases":["superhero"]},{"emoji":"🦸‍♂️","aliases":["superhero_man"]},{"emoji":"🦸‍♀️","aliases":["superhero_woman"]},{"emoji":"🦹","aliases":["supervillain"]},{"emoji":"🦹‍♂️","aliases":["supervillain_man"]},{"emoji":"🦹‍♀️","aliases":["supervillain_woman"]},{"emoji":"🏄","aliases":["surfer"]},{"emoji":"🏄‍♂️","aliases":["surfing_man"]},{"emoji":"🏄‍♀️","aliases":["surfing_woman"]},{"emoji":"🇸🇷","aliases":["suriname"]},{"emoji":"🍣","aliases":["sushi"]},{"emoji":"🚟","aliases":["suspension_railway"]},{"emoji":"🇸🇯","aliases":["svalbard_jan_mayen"]},{"emoji":"🦢","aliases":["swan"]},{"emoji":"🇸🇿","aliases":["swaziland"]},{"emoji":"😓","aliases":["sweat"]},{"emoji":"💦","aliases":["sweat_drops"]},{"emoji":"😅","aliases":["sweat_smile"]},{"emoji":"🇸🇪","aliases":["sweden"]},{"emoji":"🍠","aliases":["sweet_potato"]},{"emoji":"🩲","aliases":["swim_brief"]},{"emoji":"🏊","aliases":["swimmer"]},{"emoji":"🏊‍♂️","aliases":["swimming_man"]},{"emoji":"🏊‍♀️","aliases":["swimming_woman"]},{"emoji":"🇨🇭","aliases":["switzerland"]},{"emoji":"🔣","aliases":["symbols"]},{"emoji":"🕍","aliases":["synagogue"]},{"emoji":"🇸🇾","aliases":["syria"]},{"emoji":"💉","aliases":["syringe"]},{"emoji":"🦖","aliases":["t-rex"]},{"emoji":"🌮","aliases":["taco"]},{"emoji":"🎉","aliases":["tada","hooray"]},{"emoji":"🇹🇼","aliases":["taiwan"]},{"emoji":"🇹🇯","aliases":["tajikistan"]},{"emoji":"🥡","aliases":["takeout_box"]},{"emoji":"🫔","aliases":["tamale"]},{"emoji":"🎋","aliases":["tanabata_tree"]},{"emoji":"🍊","aliases":["tangerine","orange","mandarin"]},{"emoji":"🇹🇿","aliases":["tanzania"]},{"emoji":"♉","aliases":["taurus"]},{"emoji":"🚕","aliases":["taxi"]},{"emoji":"🍵","aliases":["tea"]},{"emoji":"🧑‍🏫","aliases":["teacher"]},{"emoji":"🫖","aliases":["teapot"]},{"emoji":"🧑‍💻","aliases":["technologist"]},{"emoji":"🧸","aliases":["teddy_bear"]},{"emoji":"📞","aliases":["telephone_receiver"]},{"emoji":"🔭","aliases":["telescope"]},{"emoji":"🎾","aliases":["tennis"]},{"emoji":"⛺","aliases":["tent"]},{"emoji":"🧪","aliases":["test_tube"]},{"emoji":"🇹🇭","aliases":["thailand"]},{"emoji":"🌡️","aliases":["thermometer"]},{"emoji":"🤔","aliases":["thinking"]},{"emoji":"🩴","aliases":["thong_sandal"]},{"emoji":"💭","aliases":["thought_balloon"]},{"emoji":"🧵","aliases":["thread"]},{"emoji":"3️⃣","aliases":["three"]},{"emoji":"🎫","aliases":["ticket"]},{"emoji":"🎟️","aliases":["tickets"]},{"emoji":"🐯","aliases":["tiger"]},{"emoji":"🐅","aliases":["tiger2"]},{"emoji":"⏲️","aliases":["timer_clock"]},{"emoji":"🇹🇱","aliases":["timor_leste"]},{"emoji":"💁‍♂️","aliases":["tipping_hand_man","sassy_man"]},{"emoji":"💁","aliases":["tipping_hand_person","information_desk_person"]},{"emoji":"💁‍♀️","aliases":["tipping_hand_woman","sassy_woman"]},{"emoji":"😫","aliases":["tired_face"]},{"emoji":"™️","aliases":["tm"]},{"emoji":"🇹🇬","aliases":["togo"]},{"emoji":"🚽","aliases":["toilet"]},{"emoji":"🇹🇰","aliases":["tokelau"]},{"emoji":"🗼","aliases":["tokyo_tower"]},{"emoji":"🍅","aliases":["tomato"]},{"emoji":"🇹🇴","aliases":["tonga"]},{"emoji":"👅","aliases":["tongue"]},{"emoji":"🧰","aliases":["toolbox"]},{"emoji":"🦷","aliases":["tooth"]},{"emoji":"🪥","aliases":["toothbrush"]},{"emoji":"🔝","aliases":["top"]},{"emoji":"🎩","aliases":["tophat"]},{"emoji":"🌪️","aliases":["tornado"]},{"emoji":"🇹🇷","aliases":["tr"]},{"emoji":"🖲️","aliases":["trackball"]},{"emoji":"🚜","aliases":["tractor"]},{"emoji":"🚥","aliases":["traffic_light"]},{"emoji":"🚋","aliases":["train"]},{"emoji":"🚆","aliases":["train2"]},{"emoji":"🚊","aliases":["tram"]},{"emoji":"🏳️‍⚧️","aliases":["transgender_flag"]},{"emoji":"⚧️","aliases":["transgender_symbol"]},{"emoji":"🚩","aliases":["triangular_flag_on_post"]},{"emoji":"📐","aliases":["triangular_ruler"]},{"emoji":"🔱","aliases":["trident"]},{"emoji":"🇹🇹","aliases":["trinidad_tobago"]},{"emoji":"🇹🇦","aliases":["tristan_da_cunha"]},{"emoji":"😤","aliases":["triumph"]},{"emoji":"🧌","aliases":["troll"]},{"emoji":"🚎","aliases":["trolleybus"]},{"emoji":"🏆","aliases":["trophy"]},{"emoji":"🍹","aliases":["tropical_drink"]},{"emoji":"🐠","aliases":["tropical_fish"]},{"emoji":"🚚","aliases":["truck"]},{"emoji":"🎺","aliases":["trumpet"]},{"emoji":"🌷","aliases":["tulip"]},{"emoji":"🥃","aliases":["tumbler_glass"]},{"emoji":"🇹🇳","aliases":["tunisia"]},{"emoji":"🦃","aliases":["turkey"]},{"emoji":"🇹🇲","aliases":["turkmenistan"]},{"emoji":"🇹🇨","aliases":["turks_caicos_islands"]},{"emoji":"🐢","aliases":["turtle"]},{"emoji":"🇹🇻","aliases":["tuvalu"]},{"emoji":"📺","aliases":["tv"]},{"emoji":"🔀","aliases":["twisted_rightwards_arrows"]},{"emoji":"2️⃣","aliases":["two"]},{"emoji":"💕","aliases":["two_hearts"]},{"emoji":"👬","aliases":["two_men_holding_hands"]},{"emoji":"👭","aliases":["two_women_holding_hands"]},{"emoji":"🈹","aliases":["u5272"]},{"emoji":"🈴","aliases":["u5408"]},{"emoji":"🈺","aliases":["u55b6"]},{"emoji":"🈯","aliases":["u6307"]},{"emoji":"🈷️","aliases":["u6708"]},{"emoji":"🈶","aliases":["u6709"]},{"emoji":"🈵","aliases":["u6e80"]},{"emoji":"🈚","aliases":["u7121"]},{"emoji":"🈸","aliases":["u7533"]},{"emoji":"🈲","aliases":["u7981"]},{"emoji":"🈳","aliases":["u7a7a"]},{"emoji":"🇺🇬","aliases":["uganda"]},{"emoji":"🇺🇦","aliases":["ukraine"]},{"emoji":"☔","aliases":["umbrella"]},{"emoji":"😒","aliases":["unamused"]},{"emoji":"🔞","aliases":["underage"]},{"emoji":"🦄","aliases":["unicorn"]},{"emoji":"🇦🇪","aliases":["united_arab_emirates"]},{"emoji":"🇺🇳","aliases":["united_nations"]},{"emoji":"🔓","aliases":["unlock"]},{"emoji":"🆙","aliases":["up"]},{"emoji":"🙃","aliases":["upside_down_face"]},{"emoji":"🇺🇾","aliases":["uruguay"]},{"emoji":"🇺🇸","aliases":["us"]},{"emoji":"🇺🇲","aliases":["us_outlying_islands"]},{"emoji":"🇻🇮","aliases":["us_virgin_islands"]},{"emoji":"🇺🇿","aliases":["uzbekistan"]},{"emoji":"✌️","aliases":["v"]},{"emoji":"🧛","aliases":["vampire"]},{"emoji":"🧛‍♂️","aliases":["vampire_man"]},{"emoji":"🧛‍♀️","aliases":["vampire_woman"]},{"emoji":"🇻🇺","aliases":["vanuatu"]},{"emoji":"🇻🇦","aliases":["vatican_city"]},{"emoji":"🇻🇪","aliases":["venezuela"]},{"emoji":"🚦","aliases":["vertical_traffic_light"]},{"emoji":"📼","aliases":["vhs"]},{"emoji":"📳","aliases":["vibration_mode"]},{"emoji":"📹","aliases":["video_camera"]},{"emoji":"🎮","aliases":["video_game"]},{"emoji":"🇻🇳","aliases":["vietnam"]},{"emoji":"🎻","aliases":["violin"]},{"emoji":"♍","aliases":["virgo"]},{"emoji":"🌋","aliases":["volcano"]},{"emoji":"🏐","aliases":["volleyball"]},{"emoji":"🤮","aliases":["vomiting_face"]},{"emoji":"🆚","aliases":["vs"]},{"emoji":"🖖","aliases":["vulcan_salute"]},{"emoji":"🧇","aliases":["waffle"]},{"emoji":"🏴󠁧󠁢󠁷󠁬󠁳󠁿","aliases":["wales"]},{"emoji":"🚶","aliases":["walking"]},{"emoji":"🚶‍♂️","aliases":["walking_man"]},{"emoji":"🚶‍♀️","aliases":["walking_woman"]},{"emoji":"🇼🇫","aliases":["wallis_futuna"]},{"emoji":"🌘","aliases":["waning_crescent_moon"]},{"emoji":"🌖","aliases":["waning_gibbous_moon"]},{"emoji":"⚠️","aliases":["warning"]},{"emoji":"🗑️","aliases":["wastebasket"]},{"emoji":"⌚","aliases":["watch"]},{"emoji":"🐃","aliases":["water_buffalo"]},{"emoji":"🤽","aliases":["water_polo"]},{"emoji":"🍉","aliases":["watermelon"]},{"emoji":"👋","aliases":["wave"]},{"emoji":"〰️","aliases":["wavy_dash"]},{"emoji":"🌒","aliases":["waxing_crescent_moon"]},{"emoji":"🚾","aliases":["wc"]},{"emoji":"😩","aliases":["weary"]},{"emoji":"💒","aliases":["wedding"]},{"emoji":"🏋️","aliases":["weight_lifting"]},{"emoji":"🏋️‍♂️","aliases":["weight_lifting_man"]},{"emoji":"🏋️‍♀️","aliases":["weight_lifting_woman"]},{"emoji":"🇪🇭","aliases":["western_sahara"]},{"emoji":"🐳","aliases":["whale"]},{"emoji":"🐋","aliases":["whale2"]},{"emoji":"🛞","aliases":["wheel"]},{"emoji":"☸️","aliases":["wheel_of_dharma"]},{"emoji":"♿","aliases":["wheelchair"]},{"emoji":"✅","aliases":["white_check_mark"]},{"emoji":"⚪","aliases":["white_circle"]},{"emoji":"🏳️","aliases":["white_flag"]},{"emoji":"💮","aliases":["white_flower"]},{"emoji":"👨‍🦳","aliases":["white_haired_man"]},{"emoji":"👩‍🦳","aliases":["white_haired_woman"]},{"emoji":"🤍","aliases":["white_heart"]},{"emoji":"⬜","aliases":["white_large_square"]},{"emoji":"◽","aliases":["white_medium_small_square"]},{"emoji":"◻️","aliases":["white_medium_square"]},{"emoji":"▫️","aliases":["white_small_square"]},{"emoji":"🔳","aliases":["white_square_button"]},{"emoji":"🥀","aliases":["wilted_flower"]},{"emoji":"🎐","aliases":["wind_chime"]},{"emoji":"🌬️","aliases":["wind_face"]},{"emoji":"🪟","aliases":["window"]},{"emoji":"🍷","aliases":["wine_glass"]},{"emoji":"🪽","aliases":["wing"]},{"emoji":"😉","aliases":["wink"]},{"emoji":"🛜","aliases":["wireless"]},{"emoji":"🐺","aliases":["wolf"]},{"emoji":"👩","aliases":["woman"]},{"emoji":"👩‍🎨","aliases":["woman_artist"]},{"emoji":"👩‍🚀","aliases":["woman_astronaut"]},{"emoji":"🧔‍♀️","aliases":["woman_beard"]},{"emoji":"🤸‍♀️","aliases":["woman_cartwheeling"]},{"emoji":"👩‍🍳","aliases":["woman_cook"]},{"emoji":"💃","aliases":["woman_dancing","dancer"]},{"emoji":"🤦‍♀️","aliases":["woman_facepalming"]},{"emoji":"👩‍🏭","aliases":["woman_factory_worker"]},{"emoji":"👩‍🌾","aliases":["woman_farmer"]},{"emoji":"👩‍🍼","aliases":["woman_feeding_baby"]},{"emoji":"👩‍🚒","aliases":["woman_firefighter"]},{"emoji":"👩‍⚕️","aliases":["woman_health_worker"]},{"emoji":"👩‍🦽","aliases":["woman_in_manual_wheelchair"]},{"emoji":"👩‍🦼","aliases":["woman_in_motorized_wheelchair"]},{"emoji":"🤵‍♀️","aliases":["woman_in_tuxedo"]},{"emoji":"👩‍⚖️","aliases":["woman_judge"]},{"emoji":"🤹‍♀️","aliases":["woman_juggling"]},{"emoji":"👩‍🔧","aliases":["woman_mechanic"]},{"emoji":"👩‍💼","aliases":["woman_office_worker"]},{"emoji":"👩‍✈️","aliases":["woman_pilot"]},{"emoji":"🤾‍♀️","aliases":["woman_playing_handball"]},{"emoji":"🤽‍♀️","aliases":["woman_playing_water_polo"]},{"emoji":"👩‍🔬","aliases":["woman_scientist"]},{"emoji":"🤷‍♀️","aliases":["woman_shrugging"]},{"emoji":"👩‍🎤","aliases":["woman_singer"]},{"emoji":"👩‍🎓","aliases":["woman_student"]},{"emoji":"👩‍🏫","aliases":["woman_teacher"]},{"emoji":"👩‍💻","aliases":["woman_technologist"]},{"emoji":"🧕","aliases":["woman_with_headscarf"]},{"emoji":"👩‍🦯","aliases":["woman_with_probing_cane"]},{"emoji":"👳‍♀️","aliases":["woman_with_turban"]},{"emoji":"👰‍♀️","aliases":["woman_with_veil","bride_with_veil"]},{"emoji":"👚","aliases":["womans_clothes"]},{"emoji":"👒","aliases":["womans_hat"]},{"emoji":"🤼‍♀️","aliases":["women_wrestling"]},{"emoji":"🚺","aliases":["womens"]},{"emoji":"🪵","aliases":["wood"]},{"emoji":"🥴","aliases":["woozy_face"]},{"emoji":"🗺️","aliases":["world_map"]},{"emoji":"🪱","aliases":["worm"]},{"emoji":"😟","aliases":["worried"]},{"emoji":"🔧","aliases":["wrench"]},{"emoji":"🤼","aliases":["wrestling"]},{"emoji":"✍️","aliases":["writing_hand"]},{"emoji":"❌","aliases":["x"]},{"emoji":"🩻","aliases":["x_ray"]},{"emoji":"🧶","aliases":["yarn"]},{"emoji":"🥱","aliases":["yawning_face"]},{"emoji":"🟡","aliases":["yellow_circle"]},{"emoji":"💛","aliases":["yellow_heart"]},{"emoji":"🟨","aliases":["yellow_square"]},{"emoji":"🇾🇪","aliases":["yemen"]},{"emoji":"💴","aliases":["yen"]},{"emoji":"☯️","aliases":["yin_yang"]},{"emoji":"🪀","aliases":["yo_yo"]},{"emoji":"😋","aliases":["yum"]},{"emoji":"🇿🇲","aliases":["zambia"]},{"emoji":"🤪","aliases":["zany_face"]},{"emoji":"⚡","aliases":["zap"]},{"emoji":"🦓","aliases":["zebra"]},{"emoji":"0️⃣","aliases":["zero"]},{"emoji":"🇿🇼","aliases":["zimbabwe"]},{"emoji":"🤐","aliases":["zipper_mouth_face"]},{"emoji":"🧟","aliases":["zombie"]},{"emoji":"🧟‍♂️","aliases":["zombie_man"]},{"emoji":"🧟‍♀️","aliases":["zombie_woman"]},{"emoji":"💤","aliases":["zzz"]}] \ No newline at end of file +[ + { + "emoji": "👍", + "aliases": [ + "+1", + "thumbsup" + ] + }, + { + "emoji": "👎", + "aliases": [ + "-1", + "thumbsdown" + ] + }, + { + "emoji": "💯", + "aliases": [ + "100" + ] + }, + { + "emoji": "🔢", + "aliases": [ + "1234" + ] + }, + { + "emoji": "🥇", + "aliases": [ + "1st_place_medal" + ] + }, + { + "emoji": "🥈", + "aliases": [ + "2nd_place_medal" + ] + }, + { + "emoji": "🥉", + "aliases": [ + "3rd_place_medal" + ] + }, + { + "emoji": "🎱", + "aliases": [ + "8ball" + ] + }, + { + "emoji": "🅰️", + "aliases": [ + "a" + ] + }, + { + "emoji": "🆎", + "aliases": [ + "ab" + ] + }, + { + "emoji": "🧮", + "aliases": [ + "abacus" + ] + }, + { + "emoji": "🔤", + "aliases": [ + "abc" + ] + }, + { + "emoji": "🔡", + "aliases": [ + "abcd" + ] + }, + { + "emoji": "🉑", + "aliases": [ + "accept" + ] + }, + { + "emoji": "🪗", + "aliases": [ + "accordion" + ] + }, + { + "emoji": "🩹", + "aliases": [ + "adhesive_bandage" + ] + }, + { + "emoji": "🧑", + "aliases": [ + "adult" + ] + }, + { + "emoji": "🚡", + "aliases": [ + "aerial_tramway" + ] + }, + { + "emoji": "🇦🇫", + "aliases": [ + "afghanistan" + ] + }, + { + "emoji": "✈️", + "aliases": [ + "airplane" + ] + }, + { + "emoji": "🇦🇽", + "aliases": [ + "aland_islands" + ] + }, + { + "emoji": "⏰", + "aliases": [ + "alarm_clock" + ] + }, + { + "emoji": "🇦🇱", + "aliases": [ + "albania" + ] + }, + { + "emoji": "⚗️", + "aliases": [ + "alembic" + ] + }, + { + "emoji": "🇩🇿", + "aliases": [ + "algeria" + ] + }, + { + "emoji": "👽", + "aliases": [ + "alien" + ] + }, + { + "emoji": "🚑", + "aliases": [ + "ambulance" + ] + }, + { + "emoji": "🇦🇸", + "aliases": [ + "american_samoa" + ] + }, + { + "emoji": "🏺", + "aliases": [ + "amphora" + ] + }, + { + "emoji": "🫀", + "aliases": [ + "anatomical_heart" + ] + }, + { + "emoji": "⚓", + "aliases": [ + "anchor" + ] + }, + { + "emoji": "🇦🇩", + "aliases": [ + "andorra" + ] + }, + { + "emoji": "👼", + "aliases": [ + "angel" + ] + }, + { + "emoji": "💢", + "aliases": [ + "anger" + ] + }, + { + "emoji": "🇦🇴", + "aliases": [ + "angola" + ] + }, + { + "emoji": "😠", + "aliases": [ + "angry" + ] + }, + { + "emoji": "🇦🇮", + "aliases": [ + "anguilla" + ] + }, + { + "emoji": "😧", + "aliases": [ + "anguished" + ] + }, + { + "emoji": "🐜", + "aliases": [ + "ant" + ] + }, + { + "emoji": "🇦🇶", + "aliases": [ + "antarctica" + ] + }, + { + "emoji": "🇦🇬", + "aliases": [ + "antigua_barbuda" + ] + }, + { + "emoji": "🍎", + "aliases": [ + "apple" + ] + }, + { + "emoji": "♒", + "aliases": [ + "aquarius" + ] + }, + { + "emoji": "🇦🇷", + "aliases": [ + "argentina" + ] + }, + { + "emoji": "♈", + "aliases": [ + "aries" + ] + }, + { + "emoji": "🇦🇲", + "aliases": [ + "armenia" + ] + }, + { + "emoji": "◀️", + "aliases": [ + "arrow_backward" + ] + }, + { + "emoji": "⏬", + "aliases": [ + "arrow_double_down" + ] + }, + { + "emoji": "⏫", + "aliases": [ + "arrow_double_up" + ] + }, + { + "emoji": "⬇️", + "aliases": [ + "arrow_down" + ] + }, + { + "emoji": "🔽", + "aliases": [ + "arrow_down_small" + ] + }, + { + "emoji": "▶️", + "aliases": [ + "arrow_forward" + ] + }, + { + "emoji": "⤵️", + "aliases": [ + "arrow_heading_down" + ] + }, + { + "emoji": "⤴️", + "aliases": [ + "arrow_heading_up" + ] + }, + { + "emoji": "⬅️", + "aliases": [ + "arrow_left" + ] + }, + { + "emoji": "↙️", + "aliases": [ + "arrow_lower_left" + ] + }, + { + "emoji": "↘️", + "aliases": [ + "arrow_lower_right" + ] + }, + { + "emoji": "➡️", + "aliases": [ + "arrow_right" + ] + }, + { + "emoji": "↪️", + "aliases": [ + "arrow_right_hook" + ] + }, + { + "emoji": "⬆️", + "aliases": [ + "arrow_up" + ] + }, + { + "emoji": "↕️", + "aliases": [ + "arrow_up_down" + ] + }, + { + "emoji": "🔼", + "aliases": [ + "arrow_up_small" + ] + }, + { + "emoji": "↖️", + "aliases": [ + "arrow_upper_left" + ] + }, + { + "emoji": "↗️", + "aliases": [ + "arrow_upper_right" + ] + }, + { + "emoji": "🔃", + "aliases": [ + "arrows_clockwise" + ] + }, + { + "emoji": "🔄", + "aliases": [ + "arrows_counterclockwise" + ] + }, + { + "emoji": "🎨", + "aliases": [ + "art" + ] + }, + { + "emoji": "🚛", + "aliases": [ + "articulated_lorry" + ] + }, + { + "emoji": "🛰️", + "aliases": [ + "artificial_satellite" + ] + }, + { + "emoji": "🧑‍🎨", + "aliases": [ + "artist" + ] + }, + { + "emoji": "🇦🇼", + "aliases": [ + "aruba" + ] + }, + { + "emoji": "🇦🇨", + "aliases": [ + "ascension_island" + ] + }, + { + "emoji": "*️⃣", + "aliases": [ + "asterisk" + ] + }, + { + "emoji": "😲", + "aliases": [ + "astonished" + ] + }, + { + "emoji": "🧑‍🚀", + "aliases": [ + "astronaut" + ] + }, + { + "emoji": "👟", + "aliases": [ + "athletic_shoe" + ] + }, + { + "emoji": "🏧", + "aliases": [ + "atm" + ] + }, + { + "emoji": "⚛️", + "aliases": [ + "atom_symbol" + ] + }, + { + "emoji": "🇦🇺", + "aliases": [ + "australia" + ] + }, + { + "emoji": "🇦🇹", + "aliases": [ + "austria" + ] + }, + { + "emoji": "🛺", + "aliases": [ + "auto_rickshaw" + ] + }, + { + "emoji": "🥑", + "aliases": [ + "avocado" + ] + }, + { + "emoji": "🪓", + "aliases": [ + "axe" + ] + }, + { + "emoji": "🇦🇿", + "aliases": [ + "azerbaijan" + ] + }, + { + "emoji": "🅱️", + "aliases": [ + "b" + ] + }, + { + "emoji": "👶", + "aliases": [ + "baby" + ] + }, + { + "emoji": "🍼", + "aliases": [ + "baby_bottle" + ] + }, + { + "emoji": "🐤", + "aliases": [ + "baby_chick" + ] + }, + { + "emoji": "🚼", + "aliases": [ + "baby_symbol" + ] + }, + { + "emoji": "🔙", + "aliases": [ + "back" + ] + }, + { + "emoji": "🥓", + "aliases": [ + "bacon" + ] + }, + { + "emoji": "🦡", + "aliases": [ + "badger" + ] + }, + { + "emoji": "🏸", + "aliases": [ + "badminton" + ] + }, + { + "emoji": "🥯", + "aliases": [ + "bagel" + ] + }, + { + "emoji": "🛄", + "aliases": [ + "baggage_claim" + ] + }, + { + "emoji": "🥖", + "aliases": [ + "baguette_bread" + ] + }, + { + "emoji": "🇧🇸", + "aliases": [ + "bahamas" + ] + }, + { + "emoji": "🇧🇭", + "aliases": [ + "bahrain" + ] + }, + { + "emoji": "⚖️", + "aliases": [ + "balance_scale" + ] + }, + { + "emoji": "👨‍🦲", + "aliases": [ + "bald_man" + ] + }, + { + "emoji": "👩‍🦲", + "aliases": [ + "bald_woman" + ] + }, + { + "emoji": "🩰", + "aliases": [ + "ballet_shoes" + ] + }, + { + "emoji": "🎈", + "aliases": [ + "balloon" + ] + }, + { + "emoji": "🗳️", + "aliases": [ + "ballot_box" + ] + }, + { + "emoji": "☑️", + "aliases": [ + "ballot_box_with_check" + ] + }, + { + "emoji": "🎍", + "aliases": [ + "bamboo" + ] + }, + { + "emoji": "🍌", + "aliases": [ + "banana" + ] + }, + { + "emoji": "‼️", + "aliases": [ + "bangbang" + ] + }, + { + "emoji": "🇧🇩", + "aliases": [ + "bangladesh" + ] + }, + { + "emoji": "🪕", + "aliases": [ + "banjo" + ] + }, + { + "emoji": "🏦", + "aliases": [ + "bank" + ] + }, + { + "emoji": "📊", + "aliases": [ + "bar_chart" + ] + }, + { + "emoji": "🇧🇧", + "aliases": [ + "barbados" + ] + }, + { + "emoji": "💈", + "aliases": [ + "barber" + ] + }, + { + "emoji": "⚾", + "aliases": [ + "baseball" + ] + }, + { + "emoji": "🧺", + "aliases": [ + "basket" + ] + }, + { + "emoji": "🏀", + "aliases": [ + "basketball" + ] + }, + { + "emoji": "🦇", + "aliases": [ + "bat" + ] + }, + { + "emoji": "🛀", + "aliases": [ + "bath" + ] + }, + { + "emoji": "🛁", + "aliases": [ + "bathtub" + ] + }, + { + "emoji": "🔋", + "aliases": [ + "battery" + ] + }, + { + "emoji": "🏖️", + "aliases": [ + "beach_umbrella" + ] + }, + { + "emoji": "🫘", + "aliases": [ + "beans" + ] + }, + { + "emoji": "🐻", + "aliases": [ + "bear" + ] + }, + { + "emoji": "🧔", + "aliases": [ + "bearded_person" + ] + }, + { + "emoji": "🦫", + "aliases": [ + "beaver" + ] + }, + { + "emoji": "🛏️", + "aliases": [ + "bed" + ] + }, + { + "emoji": "🐝", + "aliases": [ + "bee", + "honeybee" + ] + }, + { + "emoji": "🍺", + "aliases": [ + "beer" + ] + }, + { + "emoji": "🍻", + "aliases": [ + "beers" + ] + }, + { + "emoji": "🪲", + "aliases": [ + "beetle" + ] + }, + { + "emoji": "🔰", + "aliases": [ + "beginner" + ] + }, + { + "emoji": "🇧🇾", + "aliases": [ + "belarus" + ] + }, + { + "emoji": "🇧🇪", + "aliases": [ + "belgium" + ] + }, + { + "emoji": "🇧🇿", + "aliases": [ + "belize" + ] + }, + { + "emoji": "🔔", + "aliases": [ + "bell" + ] + }, + { + "emoji": "🫑", + "aliases": [ + "bell_pepper" + ] + }, + { + "emoji": "🛎️", + "aliases": [ + "bellhop_bell" + ] + }, + { + "emoji": "🇧🇯", + "aliases": [ + "benin" + ] + }, + { + "emoji": "🍱", + "aliases": [ + "bento" + ] + }, + { + "emoji": "🇧🇲", + "aliases": [ + "bermuda" + ] + }, + { + "emoji": "🧃", + "aliases": [ + "beverage_box" + ] + }, + { + "emoji": "🇧🇹", + "aliases": [ + "bhutan" + ] + }, + { + "emoji": "🚴", + "aliases": [ + "bicyclist" + ] + }, + { + "emoji": "🚲", + "aliases": [ + "bike" + ] + }, + { + "emoji": "🚴‍♂️", + "aliases": [ + "biking_man" + ] + }, + { + "emoji": "🚴‍♀️", + "aliases": [ + "biking_woman" + ] + }, + { + "emoji": "👙", + "aliases": [ + "bikini" + ] + }, + { + "emoji": "🧢", + "aliases": [ + "billed_cap" + ] + }, + { + "emoji": "☣️", + "aliases": [ + "biohazard" + ] + }, + { + "emoji": "🐦", + "aliases": [ + "bird" + ] + }, + { + "emoji": "🎂", + "aliases": [ + "birthday" + ] + }, + { + "emoji": "🦬", + "aliases": [ + "bison" + ] + }, + { + "emoji": "🫦", + "aliases": [ + "biting_lip" + ] + }, + { + "emoji": "🐦‍⬛", + "aliases": [ + "black_bird" + ] + }, + { + "emoji": "🐈‍⬛", + "aliases": [ + "black_cat" + ] + }, + { + "emoji": "⚫", + "aliases": [ + "black_circle" + ] + }, + { + "emoji": "🏴", + "aliases": [ + "black_flag" + ] + }, + { + "emoji": "🖤", + "aliases": [ + "black_heart" + ] + }, + { + "emoji": "🃏", + "aliases": [ + "black_joker" + ] + }, + { + "emoji": "⬛", + "aliases": [ + "black_large_square" + ] + }, + { + "emoji": "◾", + "aliases": [ + "black_medium_small_square" + ] + }, + { + "emoji": "◼️", + "aliases": [ + "black_medium_square" + ] + }, + { + "emoji": "✒️", + "aliases": [ + "black_nib" + ] + }, + { + "emoji": "▪️", + "aliases": [ + "black_small_square" + ] + }, + { + "emoji": "🔲", + "aliases": [ + "black_square_button" + ] + }, + { + "emoji": "👱‍♂️", + "aliases": [ + "blond_haired_man" + ] + }, + { + "emoji": "👱", + "aliases": [ + "blond_haired_person" + ] + }, + { + "emoji": "👱‍♀️", + "aliases": [ + "blond_haired_woman", + "blonde_woman" + ] + }, + { + "emoji": "🌼", + "aliases": [ + "blossom" + ] + }, + { + "emoji": "🐡", + "aliases": [ + "blowfish" + ] + }, + { + "emoji": "📘", + "aliases": [ + "blue_book" + ] + }, + { + "emoji": "🚙", + "aliases": [ + "blue_car" + ] + }, + { + "emoji": "💙", + "aliases": [ + "blue_heart" + ] + }, + { + "emoji": "🟦", + "aliases": [ + "blue_square" + ] + }, + { + "emoji": "🫐", + "aliases": [ + "blueberries" + ] + }, + { + "emoji": "😊", + "aliases": [ + "blush" + ] + }, + { + "emoji": "🐗", + "aliases": [ + "boar" + ] + }, + { + "emoji": "⛵", + "aliases": [ + "boat", + "sailboat" + ] + }, + { + "emoji": "🇧🇴", + "aliases": [ + "bolivia" + ] + }, + { + "emoji": "💣", + "aliases": [ + "bomb" + ] + }, + { + "emoji": "🦴", + "aliases": [ + "bone" + ] + }, + { + "emoji": "📖", + "aliases": [ + "book", + "open_book" + ] + }, + { + "emoji": "🔖", + "aliases": [ + "bookmark" + ] + }, + { + "emoji": "📑", + "aliases": [ + "bookmark_tabs" + ] + }, + { + "emoji": "📚", + "aliases": [ + "books" + ] + }, + { + "emoji": "💥", + "aliases": [ + "boom", + "collision" + ] + }, + { + "emoji": "🪃", + "aliases": [ + "boomerang" + ] + }, + { + "emoji": "👢", + "aliases": [ + "boot" + ] + }, + { + "emoji": "🇧🇦", + "aliases": [ + "bosnia_herzegovina" + ] + }, + { + "emoji": "🇧🇼", + "aliases": [ + "botswana" + ] + }, + { + "emoji": "⛹️‍♂️", + "aliases": [ + "bouncing_ball_man", + "basketball_man" + ] + }, + { + "emoji": "⛹️", + "aliases": [ + "bouncing_ball_person" + ] + }, + { + "emoji": "⛹️‍♀️", + "aliases": [ + "bouncing_ball_woman", + "basketball_woman" + ] + }, + { + "emoji": "💐", + "aliases": [ + "bouquet" + ] + }, + { + "emoji": "🇧🇻", + "aliases": [ + "bouvet_island" + ] + }, + { + "emoji": "🙇", + "aliases": [ + "bow" + ] + }, + { + "emoji": "🏹", + "aliases": [ + "bow_and_arrow" + ] + }, + { + "emoji": "🙇‍♂️", + "aliases": [ + "bowing_man" + ] + }, + { + "emoji": "🙇‍♀️", + "aliases": [ + "bowing_woman" + ] + }, + { + "emoji": "🥣", + "aliases": [ + "bowl_with_spoon" + ] + }, + { + "emoji": "🎳", + "aliases": [ + "bowling" + ] + }, + { + "emoji": "🥊", + "aliases": [ + "boxing_glove" + ] + }, + { + "emoji": "👦", + "aliases": [ + "boy" + ] + }, + { + "emoji": "🧠", + "aliases": [ + "brain" + ] + }, + { + "emoji": "🇧🇷", + "aliases": [ + "brazil" + ] + }, + { + "emoji": "🍞", + "aliases": [ + "bread" + ] + }, + { + "emoji": "🤱", + "aliases": [ + "breast_feeding" + ] + }, + { + "emoji": "🧱", + "aliases": [ + "bricks" + ] + }, + { + "emoji": "🌉", + "aliases": [ + "bridge_at_night" + ] + }, + { + "emoji": "💼", + "aliases": [ + "briefcase" + ] + }, + { + "emoji": "🇮🇴", + "aliases": [ + "british_indian_ocean_territory" + ] + }, + { + "emoji": "🇻🇬", + "aliases": [ + "british_virgin_islands" + ] + }, + { + "emoji": "🥦", + "aliases": [ + "broccoli" + ] + }, + { + "emoji": "⛓️‍💥", + "aliases": [ + "broken_chain" + ] + }, + { + "emoji": "💔", + "aliases": [ + "broken_heart" + ] + }, + { + "emoji": "🧹", + "aliases": [ + "broom" + ] + }, + { + "emoji": "🟤", + "aliases": [ + "brown_circle" + ] + }, + { + "emoji": "🤎", + "aliases": [ + "brown_heart" + ] + }, + { + "emoji": "🍄‍🟫", + "aliases": [ + "brown_mushroom" + ] + }, + { + "emoji": "🟫", + "aliases": [ + "brown_square" + ] + }, + { + "emoji": "🇧🇳", + "aliases": [ + "brunei" + ] + }, + { + "emoji": "🧋", + "aliases": [ + "bubble_tea" + ] + }, + { + "emoji": "🫧", + "aliases": [ + "bubbles" + ] + }, + { + "emoji": "🪣", + "aliases": [ + "bucket" + ] + }, + { + "emoji": "🐛", + "aliases": [ + "bug" + ] + }, + { + "emoji": "🏗️", + "aliases": [ + "building_construction" + ] + }, + { + "emoji": "💡", + "aliases": [ + "bulb" + ] + }, + { + "emoji": "🇧🇬", + "aliases": [ + "bulgaria" + ] + }, + { + "emoji": "🚅", + "aliases": [ + "bullettrain_front" + ] + }, + { + "emoji": "🚄", + "aliases": [ + "bullettrain_side" + ] + }, + { + "emoji": "🇧🇫", + "aliases": [ + "burkina_faso" + ] + }, + { + "emoji": "🌯", + "aliases": [ + "burrito" + ] + }, + { + "emoji": "🇧🇮", + "aliases": [ + "burundi" + ] + }, + { + "emoji": "🚌", + "aliases": [ + "bus" + ] + }, + { + "emoji": "🕴️", + "aliases": [ + "business_suit_levitating" + ] + }, + { + "emoji": "🚏", + "aliases": [ + "busstop" + ] + }, + { + "emoji": "👤", + "aliases": [ + "bust_in_silhouette" + ] + }, + { + "emoji": "👥", + "aliases": [ + "busts_in_silhouette" + ] + }, + { + "emoji": "🧈", + "aliases": [ + "butter" + ] + }, + { + "emoji": "🦋", + "aliases": [ + "butterfly" + ] + }, + { + "emoji": "🌵", + "aliases": [ + "cactus" + ] + }, + { + "emoji": "🍰", + "aliases": [ + "cake" + ] + }, + { + "emoji": "📆", + "aliases": [ + "calendar" + ] + }, + { + "emoji": "🤙", + "aliases": [ + "call_me_hand" + ] + }, + { + "emoji": "📲", + "aliases": [ + "calling" + ] + }, + { + "emoji": "🇰🇭", + "aliases": [ + "cambodia" + ] + }, + { + "emoji": "🐫", + "aliases": [ + "camel" + ] + }, + { + "emoji": "📷", + "aliases": [ + "camera" + ] + }, + { + "emoji": "📸", + "aliases": [ + "camera_flash" + ] + }, + { + "emoji": "🇨🇲", + "aliases": [ + "cameroon" + ] + }, + { + "emoji": "🏕️", + "aliases": [ + "camping" + ] + }, + { + "emoji": "🇨🇦", + "aliases": [ + "canada" + ] + }, + { + "emoji": "🇮🇨", + "aliases": [ + "canary_islands" + ] + }, + { + "emoji": "♋", + "aliases": [ + "cancer" + ] + }, + { + "emoji": "🕯️", + "aliases": [ + "candle" + ] + }, + { + "emoji": "🍬", + "aliases": [ + "candy" + ] + }, + { + "emoji": "🥫", + "aliases": [ + "canned_food" + ] + }, + { + "emoji": "🛶", + "aliases": [ + "canoe" + ] + }, + { + "emoji": "🇨🇻", + "aliases": [ + "cape_verde" + ] + }, + { + "emoji": "🔠", + "aliases": [ + "capital_abcd" + ] + }, + { + "emoji": "♑", + "aliases": [ + "capricorn" + ] + }, + { + "emoji": "🚗", + "aliases": [ + "car", + "red_car" + ] + }, + { + "emoji": "🗃️", + "aliases": [ + "card_file_box" + ] + }, + { + "emoji": "📇", + "aliases": [ + "card_index" + ] + }, + { + "emoji": "🗂️", + "aliases": [ + "card_index_dividers" + ] + }, + { + "emoji": "🇧🇶", + "aliases": [ + "caribbean_netherlands" + ] + }, + { + "emoji": "🎠", + "aliases": [ + "carousel_horse" + ] + }, + { + "emoji": "🪚", + "aliases": [ + "carpentry_saw" + ] + }, + { + "emoji": "🥕", + "aliases": [ + "carrot" + ] + }, + { + "emoji": "🤸", + "aliases": [ + "cartwheeling" + ] + }, + { + "emoji": "🐱", + "aliases": [ + "cat" + ] + }, + { + "emoji": "🐈", + "aliases": [ + "cat2" + ] + }, + { + "emoji": "🇰🇾", + "aliases": [ + "cayman_islands" + ] + }, + { + "emoji": "💿", + "aliases": [ + "cd" + ] + }, + { + "emoji": "🇨🇫", + "aliases": [ + "central_african_republic" + ] + }, + { + "emoji": "🇪🇦", + "aliases": [ + "ceuta_melilla" + ] + }, + { + "emoji": "🇹🇩", + "aliases": [ + "chad" + ] + }, + { + "emoji": "⛓️", + "aliases": [ + "chains" + ] + }, + { + "emoji": "🪑", + "aliases": [ + "chair" + ] + }, + { + "emoji": "🍾", + "aliases": [ + "champagne" + ] + }, + { + "emoji": "💹", + "aliases": [ + "chart" + ] + }, + { + "emoji": "📉", + "aliases": [ + "chart_with_downwards_trend" + ] + }, + { + "emoji": "📈", + "aliases": [ + "chart_with_upwards_trend" + ] + }, + { + "emoji": "🏁", + "aliases": [ + "checkered_flag" + ] + }, + { + "emoji": "🧀", + "aliases": [ + "cheese" + ] + }, + { + "emoji": "🍒", + "aliases": [ + "cherries" + ] + }, + { + "emoji": "🌸", + "aliases": [ + "cherry_blossom" + ] + }, + { + "emoji": "♟️", + "aliases": [ + "chess_pawn" + ] + }, + { + "emoji": "🌰", + "aliases": [ + "chestnut" + ] + }, + { + "emoji": "🐔", + "aliases": [ + "chicken" + ] + }, + { + "emoji": "🧒", + "aliases": [ + "child" + ] + }, + { + "emoji": "🚸", + "aliases": [ + "children_crossing" + ] + }, + { + "emoji": "🇨🇱", + "aliases": [ + "chile" + ] + }, + { + "emoji": "🐿️", + "aliases": [ + "chipmunk" + ] + }, + { + "emoji": "🍫", + "aliases": [ + "chocolate_bar" + ] + }, + { + "emoji": "🥢", + "aliases": [ + "chopsticks" + ] + }, + { + "emoji": "🇨🇽", + "aliases": [ + "christmas_island" + ] + }, + { + "emoji": "🎄", + "aliases": [ + "christmas_tree" + ] + }, + { + "emoji": "⛪", + "aliases": [ + "church" + ] + }, + { + "emoji": "🎦", + "aliases": [ + "cinema" + ] + }, + { + "emoji": "🎪", + "aliases": [ + "circus_tent" + ] + }, + { + "emoji": "🌇", + "aliases": [ + "city_sunrise" + ] + }, + { + "emoji": "🌆", + "aliases": [ + "city_sunset" + ] + }, + { + "emoji": "🏙️", + "aliases": [ + "cityscape" + ] + }, + { + "emoji": "🆑", + "aliases": [ + "cl" + ] + }, + { + "emoji": "🗜️", + "aliases": [ + "clamp" + ] + }, + { + "emoji": "👏", + "aliases": [ + "clap" + ] + }, + { + "emoji": "🎬", + "aliases": [ + "clapper" + ] + }, + { + "emoji": "🏛️", + "aliases": [ + "classical_building" + ] + }, + { + "emoji": "🧗", + "aliases": [ + "climbing" + ] + }, + { + "emoji": "🧗‍♂️", + "aliases": [ + "climbing_man" + ] + }, + { + "emoji": "🧗‍♀️", + "aliases": [ + "climbing_woman" + ] + }, + { + "emoji": "🥂", + "aliases": [ + "clinking_glasses" + ] + }, + { + "emoji": "📋", + "aliases": [ + "clipboard" + ] + }, + { + "emoji": "🇨🇵", + "aliases": [ + "clipperton_island" + ] + }, + { + "emoji": "🕐", + "aliases": [ + "clock1" + ] + }, + { + "emoji": "🕙", + "aliases": [ + "clock10" + ] + }, + { + "emoji": "🕥", + "aliases": [ + "clock1030" + ] + }, + { + "emoji": "🕚", + "aliases": [ + "clock11" + ] + }, + { + "emoji": "🕦", + "aliases": [ + "clock1130" + ] + }, + { + "emoji": "🕛", + "aliases": [ + "clock12" + ] + }, + { + "emoji": "🕧", + "aliases": [ + "clock1230" + ] + }, + { + "emoji": "🕜", + "aliases": [ + "clock130" + ] + }, + { + "emoji": "🕑", + "aliases": [ + "clock2" + ] + }, + { + "emoji": "🕝", + "aliases": [ + "clock230" + ] + }, + { + "emoji": "🕒", + "aliases": [ + "clock3" + ] + }, + { + "emoji": "🕞", + "aliases": [ + "clock330" + ] + }, + { + "emoji": "🕓", + "aliases": [ + "clock4" + ] + }, + { + "emoji": "🕟", + "aliases": [ + "clock430" + ] + }, + { + "emoji": "🕔", + "aliases": [ + "clock5" + ] + }, + { + "emoji": "🕠", + "aliases": [ + "clock530" + ] + }, + { + "emoji": "🕕", + "aliases": [ + "clock6" + ] + }, + { + "emoji": "🕡", + "aliases": [ + "clock630" + ] + }, + { + "emoji": "🕖", + "aliases": [ + "clock7" + ] + }, + { + "emoji": "🕢", + "aliases": [ + "clock730" + ] + }, + { + "emoji": "🕗", + "aliases": [ + "clock8" + ] + }, + { + "emoji": "🕣", + "aliases": [ + "clock830" + ] + }, + { + "emoji": "🕘", + "aliases": [ + "clock9" + ] + }, + { + "emoji": "🕤", + "aliases": [ + "clock930" + ] + }, + { + "emoji": "📕", + "aliases": [ + "closed_book" + ] + }, + { + "emoji": "🔐", + "aliases": [ + "closed_lock_with_key" + ] + }, + { + "emoji": "🌂", + "aliases": [ + "closed_umbrella" + ] + }, + { + "emoji": "☁️", + "aliases": [ + "cloud" + ] + }, + { + "emoji": "🌩️", + "aliases": [ + "cloud_with_lightning" + ] + }, + { + "emoji": "⛈️", + "aliases": [ + "cloud_with_lightning_and_rain" + ] + }, + { + "emoji": "🌧️", + "aliases": [ + "cloud_with_rain" + ] + }, + { + "emoji": "🌨️", + "aliases": [ + "cloud_with_snow" + ] + }, + { + "emoji": "🤡", + "aliases": [ + "clown_face" + ] + }, + { + "emoji": "♣️", + "aliases": [ + "clubs" + ] + }, + { + "emoji": "🇨🇳", + "aliases": [ + "cn" + ] + }, + { + "emoji": "🧥", + "aliases": [ + "coat" + ] + }, + { + "emoji": "🪳", + "aliases": [ + "cockroach" + ] + }, + { + "emoji": "🍸", + "aliases": [ + "cocktail" + ] + }, + { + "emoji": "🥥", + "aliases": [ + "coconut" + ] + }, + { + "emoji": "🇨🇨", + "aliases": [ + "cocos_islands" + ] + }, + { + "emoji": "☕", + "aliases": [ + "coffee" + ] + }, + { + "emoji": "⚰️", + "aliases": [ + "coffin" + ] + }, + { + "emoji": "🪙", + "aliases": [ + "coin" + ] + }, + { + "emoji": "🥶", + "aliases": [ + "cold_face" + ] + }, + { + "emoji": "😰", + "aliases": [ + "cold_sweat" + ] + }, + { + "emoji": "🇨🇴", + "aliases": [ + "colombia" + ] + }, + { + "emoji": "☄️", + "aliases": [ + "comet" + ] + }, + { + "emoji": "🇰🇲", + "aliases": [ + "comoros" + ] + }, + { + "emoji": "🧭", + "aliases": [ + "compass" + ] + }, + { + "emoji": "💻", + "aliases": [ + "computer" + ] + }, + { + "emoji": "🖱️", + "aliases": [ + "computer_mouse" + ] + }, + { + "emoji": "🎊", + "aliases": [ + "confetti_ball" + ] + }, + { + "emoji": "😖", + "aliases": [ + "confounded" + ] + }, + { + "emoji": "😕", + "aliases": [ + "confused" + ] + }, + { + "emoji": "🇨🇬", + "aliases": [ + "congo_brazzaville" + ] + }, + { + "emoji": "🇨🇩", + "aliases": [ + "congo_kinshasa" + ] + }, + { + "emoji": "㊗️", + "aliases": [ + "congratulations" + ] + }, + { + "emoji": "🚧", + "aliases": [ + "construction" + ] + }, + { + "emoji": "👷", + "aliases": [ + "construction_worker" + ] + }, + { + "emoji": "👷‍♂️", + "aliases": [ + "construction_worker_man" + ] + }, + { + "emoji": "👷‍♀️", + "aliases": [ + "construction_worker_woman" + ] + }, + { + "emoji": "🎛️", + "aliases": [ + "control_knobs" + ] + }, + { + "emoji": "🏪", + "aliases": [ + "convenience_store" + ] + }, + { + "emoji": "🧑‍🍳", + "aliases": [ + "cook" + ] + }, + { + "emoji": "🇨🇰", + "aliases": [ + "cook_islands" + ] + }, + { + "emoji": "🍪", + "aliases": [ + "cookie" + ] + }, + { + "emoji": "🆒", + "aliases": [ + "cool" + ] + }, + { + "emoji": "©️", + "aliases": [ + "copyright" + ] + }, + { + "emoji": "🪸", + "aliases": [ + "coral" + ] + }, + { + "emoji": "🌽", + "aliases": [ + "corn" + ] + }, + { + "emoji": "🇨🇷", + "aliases": [ + "costa_rica" + ] + }, + { + "emoji": "🇨🇮", + "aliases": [ + "cote_divoire" + ] + }, + { + "emoji": "🛋️", + "aliases": [ + "couch_and_lamp" + ] + }, + { + "emoji": "👫", + "aliases": [ + "couple" + ] + }, + { + "emoji": "💑", + "aliases": [ + "couple_with_heart" + ] + }, + { + "emoji": "👨‍❤️‍👨", + "aliases": [ + "couple_with_heart_man_man" + ] + }, + { + "emoji": "👩‍❤️‍👨", + "aliases": [ + "couple_with_heart_woman_man" + ] + }, + { + "emoji": "👩‍❤️‍👩", + "aliases": [ + "couple_with_heart_woman_woman" + ] + }, + { + "emoji": "💏", + "aliases": [ + "couplekiss" + ] + }, + { + "emoji": "👨‍❤️‍💋‍👨", + "aliases": [ + "couplekiss_man_man" + ] + }, + { + "emoji": "👩‍❤️‍💋‍👨", + "aliases": [ + "couplekiss_man_woman" + ] + }, + { + "emoji": "👩‍❤️‍💋‍👩", + "aliases": [ + "couplekiss_woman_woman" + ] + }, + { + "emoji": "🐮", + "aliases": [ + "cow" + ] + }, + { + "emoji": "🐄", + "aliases": [ + "cow2" + ] + }, + { + "emoji": "🤠", + "aliases": [ + "cowboy_hat_face" + ] + }, + { + "emoji": "🦀", + "aliases": [ + "crab" + ] + }, + { + "emoji": "🖍️", + "aliases": [ + "crayon" + ] + }, + { + "emoji": "💳", + "aliases": [ + "credit_card" + ] + }, + { + "emoji": "🌙", + "aliases": [ + "crescent_moon" + ] + }, + { + "emoji": "🦗", + "aliases": [ + "cricket" + ] + }, + { + "emoji": "🏏", + "aliases": [ + "cricket_game" + ] + }, + { + "emoji": "🇭🇷", + "aliases": [ + "croatia" + ] + }, + { + "emoji": "🐊", + "aliases": [ + "crocodile" + ] + }, + { + "emoji": "🥐", + "aliases": [ + "croissant" + ] + }, + { + "emoji": "🤞", + "aliases": [ + "crossed_fingers" + ] + }, + { + "emoji": "🎌", + "aliases": [ + "crossed_flags" + ] + }, + { + "emoji": "⚔️", + "aliases": [ + "crossed_swords" + ] + }, + { + "emoji": "👑", + "aliases": [ + "crown" + ] + }, + { + "emoji": "🩼", + "aliases": [ + "crutch" + ] + }, + { + "emoji": "😢", + "aliases": [ + "cry" + ] + }, + { + "emoji": "😿", + "aliases": [ + "crying_cat_face" + ] + }, + { + "emoji": "🔮", + "aliases": [ + "crystal_ball" + ] + }, + { + "emoji": "🇨🇺", + "aliases": [ + "cuba" + ] + }, + { + "emoji": "🥒", + "aliases": [ + "cucumber" + ] + }, + { + "emoji": "🥤", + "aliases": [ + "cup_with_straw" + ] + }, + { + "emoji": "🧁", + "aliases": [ + "cupcake" + ] + }, + { + "emoji": "💘", + "aliases": [ + "cupid" + ] + }, + { + "emoji": "🇨🇼", + "aliases": [ + "curacao" + ] + }, + { + "emoji": "🥌", + "aliases": [ + "curling_stone" + ] + }, + { + "emoji": "👨‍🦱", + "aliases": [ + "curly_haired_man" + ] + }, + { + "emoji": "👩‍🦱", + "aliases": [ + "curly_haired_woman" + ] + }, + { + "emoji": "➰", + "aliases": [ + "curly_loop" + ] + }, + { + "emoji": "💱", + "aliases": [ + "currency_exchange" + ] + }, + { + "emoji": "🍛", + "aliases": [ + "curry" + ] + }, + { + "emoji": "🤬", + "aliases": [ + "cursing_face" + ] + }, + { + "emoji": "🍮", + "aliases": [ + "custard" + ] + }, + { + "emoji": "🛃", + "aliases": [ + "customs" + ] + }, + { + "emoji": "🥩", + "aliases": [ + "cut_of_meat" + ] + }, + { + "emoji": "🌀", + "aliases": [ + "cyclone" + ] + }, + { + "emoji": "🇨🇾", + "aliases": [ + "cyprus" + ] + }, + { + "emoji": "🇨🇿", + "aliases": [ + "czech_republic" + ] + }, + { + "emoji": "🗡️", + "aliases": [ + "dagger" + ] + }, + { + "emoji": "👯", + "aliases": [ + "dancers" + ] + }, + { + "emoji": "👯‍♂️", + "aliases": [ + "dancing_men" + ] + }, + { + "emoji": "👯‍♀️", + "aliases": [ + "dancing_women" + ] + }, + { + "emoji": "🍡", + "aliases": [ + "dango" + ] + }, + { + "emoji": "🕶️", + "aliases": [ + "dark_sunglasses" + ] + }, + { + "emoji": "🎯", + "aliases": [ + "dart" + ] + }, + { + "emoji": "💨", + "aliases": [ + "dash" + ] + }, + { + "emoji": "📅", + "aliases": [ + "date" + ] + }, + { + "emoji": "🇩🇪", + "aliases": [ + "de" + ] + }, + { + "emoji": "🧏‍♂️", + "aliases": [ + "deaf_man" + ] + }, + { + "emoji": "🧏", + "aliases": [ + "deaf_person" + ] + }, + { + "emoji": "🧏‍♀️", + "aliases": [ + "deaf_woman" + ] + }, + { + "emoji": "🌳", + "aliases": [ + "deciduous_tree" + ] + }, + { + "emoji": "🦌", + "aliases": [ + "deer" + ] + }, + { + "emoji": "🇩🇰", + "aliases": [ + "denmark" + ] + }, + { + "emoji": "🏬", + "aliases": [ + "department_store" + ] + }, + { + "emoji": "🏚️", + "aliases": [ + "derelict_house" + ] + }, + { + "emoji": "🏜️", + "aliases": [ + "desert" + ] + }, + { + "emoji": "🏝️", + "aliases": [ + "desert_island" + ] + }, + { + "emoji": "🖥️", + "aliases": [ + "desktop_computer" + ] + }, + { + "emoji": "🕵️", + "aliases": [ + "detective" + ] + }, + { + "emoji": "💠", + "aliases": [ + "diamond_shape_with_a_dot_inside" + ] + }, + { + "emoji": "♦️", + "aliases": [ + "diamonds" + ] + }, + { + "emoji": "🇩🇬", + "aliases": [ + "diego_garcia" + ] + }, + { + "emoji": "😞", + "aliases": [ + "disappointed" + ] + }, + { + "emoji": "😥", + "aliases": [ + "disappointed_relieved" + ] + }, + { + "emoji": "🥸", + "aliases": [ + "disguised_face" + ] + }, + { + "emoji": "🤿", + "aliases": [ + "diving_mask" + ] + }, + { + "emoji": "🪔", + "aliases": [ + "diya_lamp" + ] + }, + { + "emoji": "💫", + "aliases": [ + "dizzy" + ] + }, + { + "emoji": "😵", + "aliases": [ + "dizzy_face" + ] + }, + { + "emoji": "🇩🇯", + "aliases": [ + "djibouti" + ] + }, + { + "emoji": "🧬", + "aliases": [ + "dna" + ] + }, + { + "emoji": "🚯", + "aliases": [ + "do_not_litter" + ] + }, + { + "emoji": "🦤", + "aliases": [ + "dodo" + ] + }, + { + "emoji": "🐶", + "aliases": [ + "dog" + ] + }, + { + "emoji": "🐕", + "aliases": [ + "dog2" + ] + }, + { + "emoji": "💵", + "aliases": [ + "dollar" + ] + }, + { + "emoji": "🎎", + "aliases": [ + "dolls" + ] + }, + { + "emoji": "🐬", + "aliases": [ + "dolphin", + "flipper" + ] + }, + { + "emoji": "🇩🇲", + "aliases": [ + "dominica" + ] + }, + { + "emoji": "🇩🇴", + "aliases": [ + "dominican_republic" + ] + }, + { + "emoji": "🫏", + "aliases": [ + "donkey" + ] + }, + { + "emoji": "🚪", + "aliases": [ + "door" + ] + }, + { + "emoji": "🫥", + "aliases": [ + "dotted_line_face" + ] + }, + { + "emoji": "🍩", + "aliases": [ + "doughnut" + ] + }, + { + "emoji": "🕊️", + "aliases": [ + "dove" + ] + }, + { + "emoji": "🐉", + "aliases": [ + "dragon" + ] + }, + { + "emoji": "🐲", + "aliases": [ + "dragon_face" + ] + }, + { + "emoji": "👗", + "aliases": [ + "dress" + ] + }, + { + "emoji": "🐪", + "aliases": [ + "dromedary_camel" + ] + }, + { + "emoji": "🤤", + "aliases": [ + "drooling_face" + ] + }, + { + "emoji": "🩸", + "aliases": [ + "drop_of_blood" + ] + }, + { + "emoji": "💧", + "aliases": [ + "droplet" + ] + }, + { + "emoji": "🥁", + "aliases": [ + "drum" + ] + }, + { + "emoji": "🦆", + "aliases": [ + "duck" + ] + }, + { + "emoji": "🥟", + "aliases": [ + "dumpling" + ] + }, + { + "emoji": "📀", + "aliases": [ + "dvd" + ] + }, + { + "emoji": "🦅", + "aliases": [ + "eagle" + ] + }, + { + "emoji": "👂", + "aliases": [ + "ear" + ] + }, + { + "emoji": "🌾", + "aliases": [ + "ear_of_rice" + ] + }, + { + "emoji": "🦻", + "aliases": [ + "ear_with_hearing_aid" + ] + }, + { + "emoji": "🌍", + "aliases": [ + "earth_africa" + ] + }, + { + "emoji": "🌎", + "aliases": [ + "earth_americas" + ] + }, + { + "emoji": "🌏", + "aliases": [ + "earth_asia" + ] + }, + { + "emoji": "🇪🇨", + "aliases": [ + "ecuador" + ] + }, + { + "emoji": "🥚", + "aliases": [ + "egg" + ] + }, + { + "emoji": "🍆", + "aliases": [ + "eggplant" + ] + }, + { + "emoji": "🇪🇬", + "aliases": [ + "egypt" + ] + }, + { + "emoji": "8️⃣", + "aliases": [ + "eight" + ] + }, + { + "emoji": "✴️", + "aliases": [ + "eight_pointed_black_star" + ] + }, + { + "emoji": "✳️", + "aliases": [ + "eight_spoked_asterisk" + ] + }, + { + "emoji": "⏏️", + "aliases": [ + "eject_button" + ] + }, + { + "emoji": "🇸🇻", + "aliases": [ + "el_salvador" + ] + }, + { + "emoji": "🔌", + "aliases": [ + "electric_plug" + ] + }, + { + "emoji": "🐘", + "aliases": [ + "elephant" + ] + }, + { + "emoji": "🛗", + "aliases": [ + "elevator" + ] + }, + { + "emoji": "🧝", + "aliases": [ + "elf" + ] + }, + { + "emoji": "🧝‍♂️", + "aliases": [ + "elf_man" + ] + }, + { + "emoji": "🧝‍♀️", + "aliases": [ + "elf_woman" + ] + }, + { + "emoji": "📧", + "aliases": [ + "email", + "e-mail" + ] + }, + { + "emoji": "🪹", + "aliases": [ + "empty_nest" + ] + }, + { + "emoji": "🔚", + "aliases": [ + "end" + ] + }, + { + "emoji": "🏴󠁧󠁢󠁥󠁮󠁧󠁿", + "aliases": [ + "england" + ] + }, + { + "emoji": "✉️", + "aliases": [ + "envelope" + ] + }, + { + "emoji": "📩", + "aliases": [ + "envelope_with_arrow" + ] + }, + { + "emoji": "🇬🇶", + "aliases": [ + "equatorial_guinea" + ] + }, + { + "emoji": "🇪🇷", + "aliases": [ + "eritrea" + ] + }, + { + "emoji": "🇪🇸", + "aliases": [ + "es" + ] + }, + { + "emoji": "🇪🇪", + "aliases": [ + "estonia" + ] + }, + { + "emoji": "🇪🇹", + "aliases": [ + "ethiopia" + ] + }, + { + "emoji": "🇪🇺", + "aliases": [ + "eu", + "european_union" + ] + }, + { + "emoji": "💶", + "aliases": [ + "euro" + ] + }, + { + "emoji": "🏰", + "aliases": [ + "european_castle" + ] + }, + { + "emoji": "🏤", + "aliases": [ + "european_post_office" + ] + }, + { + "emoji": "🌲", + "aliases": [ + "evergreen_tree" + ] + }, + { + "emoji": "❗", + "aliases": [ + "exclamation", + "heavy_exclamation_mark" + ] + }, + { + "emoji": "🤯", + "aliases": [ + "exploding_head" + ] + }, + { + "emoji": "😑", + "aliases": [ + "expressionless" + ] + }, + { + "emoji": "👁️", + "aliases": [ + "eye" + ] + }, + { + "emoji": "👁️‍🗨️", + "aliases": [ + "eye_speech_bubble" + ] + }, + { + "emoji": "👓", + "aliases": [ + "eyeglasses" + ] + }, + { + "emoji": "👀", + "aliases": [ + "eyes" + ] + }, + { + "emoji": "😮‍💨", + "aliases": [ + "face_exhaling" + ] + }, + { + "emoji": "🥹", + "aliases": [ + "face_holding_back_tears" + ] + }, + { + "emoji": "😶‍🌫️", + "aliases": [ + "face_in_clouds" + ] + }, + { + "emoji": "🫩", + "aliases": [ + "face_with_bags_under_eyes" + ] + }, + { + "emoji": "🫤", + "aliases": [ + "face_with_diagonal_mouth" + ] + }, + { + "emoji": "🤕", + "aliases": [ + "face_with_head_bandage" + ] + }, + { + "emoji": "🫢", + "aliases": [ + "face_with_open_eyes_and_hand_over_mouth" + ] + }, + { + "emoji": "🫣", + "aliases": [ + "face_with_peeking_eye" + ] + }, + { + "emoji": "😵‍💫", + "aliases": [ + "face_with_spiral_eyes" + ] + }, + { + "emoji": "🤒", + "aliases": [ + "face_with_thermometer" + ] + }, + { + "emoji": "🤦", + "aliases": [ + "facepalm" + ] + }, + { + "emoji": "🏭", + "aliases": [ + "factory" + ] + }, + { + "emoji": "🧑‍🏭", + "aliases": [ + "factory_worker" + ] + }, + { + "emoji": "🧚", + "aliases": [ + "fairy" + ] + }, + { + "emoji": "🧚‍♂️", + "aliases": [ + "fairy_man" + ] + }, + { + "emoji": "🧚‍♀️", + "aliases": [ + "fairy_woman" + ] + }, + { + "emoji": "🧆", + "aliases": [ + "falafel" + ] + }, + { + "emoji": "🇫🇰", + "aliases": [ + "falkland_islands" + ] + }, + { + "emoji": "🍂", + "aliases": [ + "fallen_leaf" + ] + }, + { + "emoji": "👪", + "aliases": [ + "family" + ] + }, + { + "emoji": "🧑‍🧑‍🧒", + "aliases": [ + "family_adult_adult_child" + ] + }, + { + "emoji": "🧑‍🧑‍🧒‍🧒", + "aliases": [ + "family_adult_adult_child_child" + ] + }, + { + "emoji": "🧑‍🧒", + "aliases": [ + "family_adult_child" + ] + }, + { + "emoji": "🧑‍🧒‍🧒", + "aliases": [ + "family_adult_child_child" + ] + }, + { + "emoji": "👨‍👦", + "aliases": [ + "family_man_boy" + ] + }, + { + "emoji": "👨‍👦‍👦", + "aliases": [ + "family_man_boy_boy" + ] + }, + { + "emoji": "👨‍👧", + "aliases": [ + "family_man_girl" + ] + }, + { + "emoji": "👨‍👧‍👦", + "aliases": [ + "family_man_girl_boy" + ] + }, + { + "emoji": "👨‍👧‍👧", + "aliases": [ + "family_man_girl_girl" + ] + }, + { + "emoji": "👨‍👨‍👦", + "aliases": [ + "family_man_man_boy" + ] + }, + { + "emoji": "👨‍👨‍👦‍👦", + "aliases": [ + "family_man_man_boy_boy" + ] + }, + { + "emoji": "👨‍👨‍👧", + "aliases": [ + "family_man_man_girl" + ] + }, + { + "emoji": "👨‍👨‍👧‍👦", + "aliases": [ + "family_man_man_girl_boy" + ] + }, + { + "emoji": "👨‍👨‍👧‍👧", + "aliases": [ + "family_man_man_girl_girl" + ] + }, + { + "emoji": "👨‍👩‍👦", + "aliases": [ + "family_man_woman_boy" + ] + }, + { + "emoji": "👨‍👩‍👦‍👦", + "aliases": [ + "family_man_woman_boy_boy" + ] + }, + { + "emoji": "👨‍👩‍👧", + "aliases": [ + "family_man_woman_girl" + ] + }, + { + "emoji": "👨‍👩‍👧‍👦", + "aliases": [ + "family_man_woman_girl_boy" + ] + }, + { + "emoji": "👨‍👩‍👧‍👧", + "aliases": [ + "family_man_woman_girl_girl" + ] + }, + { + "emoji": "👩‍👦", + "aliases": [ + "family_woman_boy" + ] + }, + { + "emoji": "👩‍👦‍👦", + "aliases": [ + "family_woman_boy_boy" + ] + }, + { + "emoji": "👩‍👧", + "aliases": [ + "family_woman_girl" + ] + }, + { + "emoji": "👩‍👧‍👦", + "aliases": [ + "family_woman_girl_boy" + ] + }, + { + "emoji": "👩‍👧‍👧", + "aliases": [ + "family_woman_girl_girl" + ] + }, + { + "emoji": "👩‍👩‍👦", + "aliases": [ + "family_woman_woman_boy" + ] + }, + { + "emoji": "👩‍👩‍👦‍👦", + "aliases": [ + "family_woman_woman_boy_boy" + ] + }, + { + "emoji": "👩‍👩‍👧", + "aliases": [ + "family_woman_woman_girl" + ] + }, + { + "emoji": "👩‍👩‍👧‍👦", + "aliases": [ + "family_woman_woman_girl_boy" + ] + }, + { + "emoji": "👩‍👩‍👧‍👧", + "aliases": [ + "family_woman_woman_girl_girl" + ] + }, + { + "emoji": "🧑‍🌾", + "aliases": [ + "farmer" + ] + }, + { + "emoji": "🇫🇴", + "aliases": [ + "faroe_islands" + ] + }, + { + "emoji": "⏩", + "aliases": [ + "fast_forward" + ] + }, + { + "emoji": "📠", + "aliases": [ + "fax" + ] + }, + { + "emoji": "😨", + "aliases": [ + "fearful" + ] + }, + { + "emoji": "🪶", + "aliases": [ + "feather" + ] + }, + { + "emoji": "🐾", + "aliases": [ + "feet", + "paw_prints" + ] + }, + { + "emoji": "🕵️‍♀️", + "aliases": [ + "female_detective" + ] + }, + { + "emoji": "♀️", + "aliases": [ + "female_sign" + ] + }, + { + "emoji": "🎡", + "aliases": [ + "ferris_wheel" + ] + }, + { + "emoji": "⛴️", + "aliases": [ + "ferry" + ] + }, + { + "emoji": "🏑", + "aliases": [ + "field_hockey" + ] + }, + { + "emoji": "🇫🇯", + "aliases": [ + "fiji" + ] + }, + { + "emoji": "🗄️", + "aliases": [ + "file_cabinet" + ] + }, + { + "emoji": "📁", + "aliases": [ + "file_folder" + ] + }, + { + "emoji": "📽️", + "aliases": [ + "film_projector" + ] + }, + { + "emoji": "🎞️", + "aliases": [ + "film_strip" + ] + }, + { + "emoji": "🫆", + "aliases": [ + "fingerprint" + ] + }, + { + "emoji": "🇫🇮", + "aliases": [ + "finland" + ] + }, + { + "emoji": "🔥", + "aliases": [ + "fire" + ] + }, + { + "emoji": "🚒", + "aliases": [ + "fire_engine" + ] + }, + { + "emoji": "🧯", + "aliases": [ + "fire_extinguisher" + ] + }, + { + "emoji": "🧨", + "aliases": [ + "firecracker" + ] + }, + { + "emoji": "🧑‍🚒", + "aliases": [ + "firefighter" + ] + }, + { + "emoji": "🎆", + "aliases": [ + "fireworks" + ] + }, + { + "emoji": "🌓", + "aliases": [ + "first_quarter_moon" + ] + }, + { + "emoji": "🌛", + "aliases": [ + "first_quarter_moon_with_face" + ] + }, + { + "emoji": "🐟", + "aliases": [ + "fish" + ] + }, + { + "emoji": "🍥", + "aliases": [ + "fish_cake" + ] + }, + { + "emoji": "🎣", + "aliases": [ + "fishing_pole_and_fish" + ] + }, + { + "emoji": "🤛", + "aliases": [ + "fist_left" + ] + }, + { + "emoji": "👊", + "aliases": [ + "fist_oncoming", + "facepunch", + "punch" + ] + }, + { + "emoji": "✊", + "aliases": [ + "fist_raised", + "fist" + ] + }, + { + "emoji": "🤜", + "aliases": [ + "fist_right" + ] + }, + { + "emoji": "5️⃣", + "aliases": [ + "five" + ] + }, + { + "emoji": "🇨🇶", + "aliases": [ + "flag_sark" + ] + }, + { + "emoji": "🎏", + "aliases": [ + "flags" + ] + }, + { + "emoji": "🦩", + "aliases": [ + "flamingo" + ] + }, + { + "emoji": "🔦", + "aliases": [ + "flashlight" + ] + }, + { + "emoji": "🥿", + "aliases": [ + "flat_shoe" + ] + }, + { + "emoji": "🫓", + "aliases": [ + "flatbread" + ] + }, + { + "emoji": "⚜️", + "aliases": [ + "fleur_de_lis" + ] + }, + { + "emoji": "🛬", + "aliases": [ + "flight_arrival" + ] + }, + { + "emoji": "🛫", + "aliases": [ + "flight_departure" + ] + }, + { + "emoji": "💾", + "aliases": [ + "floppy_disk" + ] + }, + { + "emoji": "🎴", + "aliases": [ + "flower_playing_cards" + ] + }, + { + "emoji": "😳", + "aliases": [ + "flushed" + ] + }, + { + "emoji": "🪈", + "aliases": [ + "flute" + ] + }, + { + "emoji": "🪰", + "aliases": [ + "fly" + ] + }, + { + "emoji": "🥏", + "aliases": [ + "flying_disc" + ] + }, + { + "emoji": "🛸", + "aliases": [ + "flying_saucer" + ] + }, + { + "emoji": "🌫️", + "aliases": [ + "fog" + ] + }, + { + "emoji": "🌁", + "aliases": [ + "foggy" + ] + }, + { + "emoji": "🪭", + "aliases": [ + "folding_hand_fan" + ] + }, + { + "emoji": "🫕", + "aliases": [ + "fondue" + ] + }, + { + "emoji": "🦶", + "aliases": [ + "foot" + ] + }, + { + "emoji": "🏈", + "aliases": [ + "football" + ] + }, + { + "emoji": "👣", + "aliases": [ + "footprints" + ] + }, + { + "emoji": "🍴", + "aliases": [ + "fork_and_knife" + ] + }, + { + "emoji": "🥠", + "aliases": [ + "fortune_cookie" + ] + }, + { + "emoji": "⛲", + "aliases": [ + "fountain" + ] + }, + { + "emoji": "🖋️", + "aliases": [ + "fountain_pen" + ] + }, + { + "emoji": "4️⃣", + "aliases": [ + "four" + ] + }, + { + "emoji": "🍀", + "aliases": [ + "four_leaf_clover" + ] + }, + { + "emoji": "🦊", + "aliases": [ + "fox_face" + ] + }, + { + "emoji": "🇫🇷", + "aliases": [ + "fr" + ] + }, + { + "emoji": "🖼️", + "aliases": [ + "framed_picture" + ] + }, + { + "emoji": "🆓", + "aliases": [ + "free" + ] + }, + { + "emoji": "🇬🇫", + "aliases": [ + "french_guiana" + ] + }, + { + "emoji": "🇵🇫", + "aliases": [ + "french_polynesia" + ] + }, + { + "emoji": "🇹🇫", + "aliases": [ + "french_southern_territories" + ] + }, + { + "emoji": "🍳", + "aliases": [ + "fried_egg" + ] + }, + { + "emoji": "🍤", + "aliases": [ + "fried_shrimp" + ] + }, + { + "emoji": "🍟", + "aliases": [ + "fries" + ] + }, + { + "emoji": "🐸", + "aliases": [ + "frog" + ] + }, + { + "emoji": "😦", + "aliases": [ + "frowning" + ] + }, + { + "emoji": "☹️", + "aliases": [ + "frowning_face" + ] + }, + { + "emoji": "🙍‍♂️", + "aliases": [ + "frowning_man" + ] + }, + { + "emoji": "🙍", + "aliases": [ + "frowning_person" + ] + }, + { + "emoji": "🙍‍♀️", + "aliases": [ + "frowning_woman" + ] + }, + { + "emoji": "⛽", + "aliases": [ + "fuelpump" + ] + }, + { + "emoji": "🌕", + "aliases": [ + "full_moon" + ] + }, + { + "emoji": "🌝", + "aliases": [ + "full_moon_with_face" + ] + }, + { + "emoji": "⚱️", + "aliases": [ + "funeral_urn" + ] + }, + { + "emoji": "🇬🇦", + "aliases": [ + "gabon" + ] + }, + { + "emoji": "🇬🇲", + "aliases": [ + "gambia" + ] + }, + { + "emoji": "🎲", + "aliases": [ + "game_die" + ] + }, + { + "emoji": "🧄", + "aliases": [ + "garlic" + ] + }, + { + "emoji": "🇬🇧", + "aliases": [ + "gb", + "uk" + ] + }, + { + "emoji": "⚙️", + "aliases": [ + "gear" + ] + }, + { + "emoji": "💎", + "aliases": [ + "gem" + ] + }, + { + "emoji": "♊", + "aliases": [ + "gemini" + ] + }, + { + "emoji": "🧞", + "aliases": [ + "genie" + ] + }, + { + "emoji": "🧞‍♂️", + "aliases": [ + "genie_man" + ] + }, + { + "emoji": "🧞‍♀️", + "aliases": [ + "genie_woman" + ] + }, + { + "emoji": "🇬🇪", + "aliases": [ + "georgia" + ] + }, + { + "emoji": "🇬🇭", + "aliases": [ + "ghana" + ] + }, + { + "emoji": "👻", + "aliases": [ + "ghost" + ] + }, + { + "emoji": "🇬🇮", + "aliases": [ + "gibraltar" + ] + }, + { + "emoji": "🎁", + "aliases": [ + "gift" + ] + }, + { + "emoji": "💝", + "aliases": [ + "gift_heart" + ] + }, + { + "emoji": "🫚", + "aliases": [ + "ginger_root" + ] + }, + { + "emoji": "🦒", + "aliases": [ + "giraffe" + ] + }, + { + "emoji": "👧", + "aliases": [ + "girl" + ] + }, + { + "emoji": "🌐", + "aliases": [ + "globe_with_meridians" + ] + }, + { + "emoji": "🧤", + "aliases": [ + "gloves" + ] + }, + { + "emoji": "🥅", + "aliases": [ + "goal_net" + ] + }, + { + "emoji": "🐐", + "aliases": [ + "goat" + ] + }, + { + "emoji": "🥽", + "aliases": [ + "goggles" + ] + }, + { + "emoji": "⛳", + "aliases": [ + "golf" + ] + }, + { + "emoji": "🏌️", + "aliases": [ + "golfing" + ] + }, + { + "emoji": "🏌️‍♂️", + "aliases": [ + "golfing_man" + ] + }, + { + "emoji": "🏌️‍♀️", + "aliases": [ + "golfing_woman" + ] + }, + { + "emoji": "🪿", + "aliases": [ + "goose" + ] + }, + { + "emoji": "🦍", + "aliases": [ + "gorilla" + ] + }, + { + "emoji": "🍇", + "aliases": [ + "grapes" + ] + }, + { + "emoji": "🇬🇷", + "aliases": [ + "greece" + ] + }, + { + "emoji": "🍏", + "aliases": [ + "green_apple" + ] + }, + { + "emoji": "📗", + "aliases": [ + "green_book" + ] + }, + { + "emoji": "🟢", + "aliases": [ + "green_circle" + ] + }, + { + "emoji": "💚", + "aliases": [ + "green_heart" + ] + }, + { + "emoji": "🥗", + "aliases": [ + "green_salad" + ] + }, + { + "emoji": "🟩", + "aliases": [ + "green_square" + ] + }, + { + "emoji": "🇬🇱", + "aliases": [ + "greenland" + ] + }, + { + "emoji": "🇬🇩", + "aliases": [ + "grenada" + ] + }, + { + "emoji": "❕", + "aliases": [ + "grey_exclamation" + ] + }, + { + "emoji": "🩶", + "aliases": [ + "grey_heart" + ] + }, + { + "emoji": "❔", + "aliases": [ + "grey_question" + ] + }, + { + "emoji": "😬", + "aliases": [ + "grimacing" + ] + }, + { + "emoji": "😁", + "aliases": [ + "grin" + ] + }, + { + "emoji": "😀", + "aliases": [ + "grinning" + ] + }, + { + "emoji": "🇬🇵", + "aliases": [ + "guadeloupe" + ] + }, + { + "emoji": "🇬🇺", + "aliases": [ + "guam" + ] + }, + { + "emoji": "💂", + "aliases": [ + "guard" + ] + }, + { + "emoji": "💂‍♂️", + "aliases": [ + "guardsman" + ] + }, + { + "emoji": "💂‍♀️", + "aliases": [ + "guardswoman" + ] + }, + { + "emoji": "🇬🇹", + "aliases": [ + "guatemala" + ] + }, + { + "emoji": "🇬🇬", + "aliases": [ + "guernsey" + ] + }, + { + "emoji": "🦮", + "aliases": [ + "guide_dog" + ] + }, + { + "emoji": "🇬🇳", + "aliases": [ + "guinea" + ] + }, + { + "emoji": "🇬🇼", + "aliases": [ + "guinea_bissau" + ] + }, + { + "emoji": "🎸", + "aliases": [ + "guitar" + ] + }, + { + "emoji": "🔫", + "aliases": [ + "gun" + ] + }, + { + "emoji": "🇬🇾", + "aliases": [ + "guyana" + ] + }, + { + "emoji": "🪮", + "aliases": [ + "hair_pick" + ] + }, + { + "emoji": "💇", + "aliases": [ + "haircut" + ] + }, + { + "emoji": "💇‍♂️", + "aliases": [ + "haircut_man" + ] + }, + { + "emoji": "💇‍♀️", + "aliases": [ + "haircut_woman" + ] + }, + { + "emoji": "🇭🇹", + "aliases": [ + "haiti" + ] + }, + { + "emoji": "🍔", + "aliases": [ + "hamburger" + ] + }, + { + "emoji": "🔨", + "aliases": [ + "hammer" + ] + }, + { + "emoji": "⚒️", + "aliases": [ + "hammer_and_pick" + ] + }, + { + "emoji": "🛠️", + "aliases": [ + "hammer_and_wrench" + ] + }, + { + "emoji": "🪬", + "aliases": [ + "hamsa" + ] + }, + { + "emoji": "🐹", + "aliases": [ + "hamster" + ] + }, + { + "emoji": "✋", + "aliases": [ + "hand", + "raised_hand" + ] + }, + { + "emoji": "🤭", + "aliases": [ + "hand_over_mouth" + ] + }, + { + "emoji": "🫰", + "aliases": [ + "hand_with_index_finger_and_thumb_crossed" + ] + }, + { + "emoji": "👜", + "aliases": [ + "handbag" + ] + }, + { + "emoji": "🤾", + "aliases": [ + "handball_person" + ] + }, + { + "emoji": "🤝", + "aliases": [ + "handshake" + ] + }, + { + "emoji": "💩", + "aliases": [ + "hankey", + "poop", + "shit" + ] + }, + { + "emoji": "🪉", + "aliases": [ + "harp" + ] + }, + { + "emoji": "#️⃣", + "aliases": [ + "hash" + ] + }, + { + "emoji": "🐥", + "aliases": [ + "hatched_chick" + ] + }, + { + "emoji": "🐣", + "aliases": [ + "hatching_chick" + ] + }, + { + "emoji": "🙂‍↔️", + "aliases": [ + "head_shaking_horizontally" + ] + }, + { + "emoji": "🙂‍↕️", + "aliases": [ + "head_shaking_vertically" + ] + }, + { + "emoji": "🎧", + "aliases": [ + "headphones" + ] + }, + { + "emoji": "🪦", + "aliases": [ + "headstone" + ] + }, + { + "emoji": "🧑‍⚕️", + "aliases": [ + "health_worker" + ] + }, + { + "emoji": "🙉", + "aliases": [ + "hear_no_evil" + ] + }, + { + "emoji": "🇭🇲", + "aliases": [ + "heard_mcdonald_islands" + ] + }, + { + "emoji": "❤️", + "aliases": [ + "heart" + ] + }, + { + "emoji": "💟", + "aliases": [ + "heart_decoration" + ] + }, + { + "emoji": "😍", + "aliases": [ + "heart_eyes" + ] + }, + { + "emoji": "😻", + "aliases": [ + "heart_eyes_cat" + ] + }, + { + "emoji": "🫶", + "aliases": [ + "heart_hands" + ] + }, + { + "emoji": "❤️‍🔥", + "aliases": [ + "heart_on_fire" + ] + }, + { + "emoji": "💓", + "aliases": [ + "heartbeat" + ] + }, + { + "emoji": "💗", + "aliases": [ + "heartpulse" + ] + }, + { + "emoji": "♥️", + "aliases": [ + "hearts" + ] + }, + { + "emoji": "✔️", + "aliases": [ + "heavy_check_mark" + ] + }, + { + "emoji": "➗", + "aliases": [ + "heavy_division_sign" + ] + }, + { + "emoji": "💲", + "aliases": [ + "heavy_dollar_sign" + ] + }, + { + "emoji": "🟰", + "aliases": [ + "heavy_equals_sign" + ] + }, + { + "emoji": "❣️", + "aliases": [ + "heavy_heart_exclamation" + ] + }, + { + "emoji": "➖", + "aliases": [ + "heavy_minus_sign" + ] + }, + { + "emoji": "✖️", + "aliases": [ + "heavy_multiplication_x" + ] + }, + { + "emoji": "➕", + "aliases": [ + "heavy_plus_sign" + ] + }, + { + "emoji": "🦔", + "aliases": [ + "hedgehog" + ] + }, + { + "emoji": "🚁", + "aliases": [ + "helicopter" + ] + }, + { + "emoji": "🌿", + "aliases": [ + "herb" + ] + }, + { + "emoji": "🌺", + "aliases": [ + "hibiscus" + ] + }, + { + "emoji": "🔆", + "aliases": [ + "high_brightness" + ] + }, + { + "emoji": "👠", + "aliases": [ + "high_heel" + ] + }, + { + "emoji": "🥾", + "aliases": [ + "hiking_boot" + ] + }, + { + "emoji": "🛕", + "aliases": [ + "hindu_temple" + ] + }, + { + "emoji": "🦛", + "aliases": [ + "hippopotamus" + ] + }, + { + "emoji": "🔪", + "aliases": [ + "hocho", + "knife" + ] + }, + { + "emoji": "🕳️", + "aliases": [ + "hole" + ] + }, + { + "emoji": "🇭🇳", + "aliases": [ + "honduras" + ] + }, + { + "emoji": "🍯", + "aliases": [ + "honey_pot" + ] + }, + { + "emoji": "🇭🇰", + "aliases": [ + "hong_kong" + ] + }, + { + "emoji": "🪝", + "aliases": [ + "hook" + ] + }, + { + "emoji": "🐴", + "aliases": [ + "horse" + ] + }, + { + "emoji": "🏇", + "aliases": [ + "horse_racing" + ] + }, + { + "emoji": "🏥", + "aliases": [ + "hospital" + ] + }, + { + "emoji": "🥵", + "aliases": [ + "hot_face" + ] + }, + { + "emoji": "🌶️", + "aliases": [ + "hot_pepper" + ] + }, + { + "emoji": "🌭", + "aliases": [ + "hotdog" + ] + }, + { + "emoji": "🏨", + "aliases": [ + "hotel" + ] + }, + { + "emoji": "♨️", + "aliases": [ + "hotsprings" + ] + }, + { + "emoji": "⌛", + "aliases": [ + "hourglass" + ] + }, + { + "emoji": "⏳", + "aliases": [ + "hourglass_flowing_sand" + ] + }, + { + "emoji": "🏠", + "aliases": [ + "house" + ] + }, + { + "emoji": "🏡", + "aliases": [ + "house_with_garden" + ] + }, + { + "emoji": "🏘️", + "aliases": [ + "houses" + ] + }, + { + "emoji": "🤗", + "aliases": [ + "hugs" + ] + }, + { + "emoji": "🇭🇺", + "aliases": [ + "hungary" + ] + }, + { + "emoji": "😯", + "aliases": [ + "hushed" + ] + }, + { + "emoji": "🛖", + "aliases": [ + "hut" + ] + }, + { + "emoji": "🪻", + "aliases": [ + "hyacinth" + ] + }, + { + "emoji": "🍨", + "aliases": [ + "ice_cream" + ] + }, + { + "emoji": "🧊", + "aliases": [ + "ice_cube" + ] + }, + { + "emoji": "🏒", + "aliases": [ + "ice_hockey" + ] + }, + { + "emoji": "⛸️", + "aliases": [ + "ice_skate" + ] + }, + { + "emoji": "🍦", + "aliases": [ + "icecream" + ] + }, + { + "emoji": "🇮🇸", + "aliases": [ + "iceland" + ] + }, + { + "emoji": "🆔", + "aliases": [ + "id" + ] + }, + { + "emoji": "🪪", + "aliases": [ + "identification_card" + ] + }, + { + "emoji": "🉐", + "aliases": [ + "ideograph_advantage" + ] + }, + { + "emoji": "👿", + "aliases": [ + "imp" + ] + }, + { + "emoji": "📥", + "aliases": [ + "inbox_tray" + ] + }, + { + "emoji": "📨", + "aliases": [ + "incoming_envelope" + ] + }, + { + "emoji": "🫵", + "aliases": [ + "index_pointing_at_the_viewer" + ] + }, + { + "emoji": "🇮🇳", + "aliases": [ + "india" + ] + }, + { + "emoji": "🇮🇩", + "aliases": [ + "indonesia" + ] + }, + { + "emoji": "♾️", + "aliases": [ + "infinity" + ] + }, + { + "emoji": "ℹ️", + "aliases": [ + "information_source" + ] + }, + { + "emoji": "😇", + "aliases": [ + "innocent" + ] + }, + { + "emoji": "⁉️", + "aliases": [ + "interrobang" + ] + }, + { + "emoji": "📱", + "aliases": [ + "iphone" + ] + }, + { + "emoji": "🇮🇷", + "aliases": [ + "iran" + ] + }, + { + "emoji": "🇮🇶", + "aliases": [ + "iraq" + ] + }, + { + "emoji": "🇮🇪", + "aliases": [ + "ireland" + ] + }, + { + "emoji": "🇮🇲", + "aliases": [ + "isle_of_man" + ] + }, + { + "emoji": "🇮🇱", + "aliases": [ + "israel" + ] + }, + { + "emoji": "🇮🇹", + "aliases": [ + "it" + ] + }, + { + "emoji": "🏮", + "aliases": [ + "izakaya_lantern", + "lantern" + ] + }, + { + "emoji": "🎃", + "aliases": [ + "jack_o_lantern" + ] + }, + { + "emoji": "🇯🇲", + "aliases": [ + "jamaica" + ] + }, + { + "emoji": "🗾", + "aliases": [ + "japan" + ] + }, + { + "emoji": "🏯", + "aliases": [ + "japanese_castle" + ] + }, + { + "emoji": "👺", + "aliases": [ + "japanese_goblin" + ] + }, + { + "emoji": "👹", + "aliases": [ + "japanese_ogre" + ] + }, + { + "emoji": "🫙", + "aliases": [ + "jar" + ] + }, + { + "emoji": "👖", + "aliases": [ + "jeans" + ] + }, + { + "emoji": "🪼", + "aliases": [ + "jellyfish" + ] + }, + { + "emoji": "🇯🇪", + "aliases": [ + "jersey" + ] + }, + { + "emoji": "🧩", + "aliases": [ + "jigsaw" + ] + }, + { + "emoji": "🇯🇴", + "aliases": [ + "jordan" + ] + }, + { + "emoji": "😂", + "aliases": [ + "joy" + ] + }, + { + "emoji": "😹", + "aliases": [ + "joy_cat" + ] + }, + { + "emoji": "🕹️", + "aliases": [ + "joystick" + ] + }, + { + "emoji": "🇯🇵", + "aliases": [ + "jp" + ] + }, + { + "emoji": "🧑‍⚖️", + "aliases": [ + "judge" + ] + }, + { + "emoji": "🤹", + "aliases": [ + "juggling_person" + ] + }, + { + "emoji": "🕋", + "aliases": [ + "kaaba" + ] + }, + { + "emoji": "🦘", + "aliases": [ + "kangaroo" + ] + }, + { + "emoji": "🇰🇿", + "aliases": [ + "kazakhstan" + ] + }, + { + "emoji": "🇰🇪", + "aliases": [ + "kenya" + ] + }, + { + "emoji": "🔑", + "aliases": [ + "key" + ] + }, + { + "emoji": "⌨️", + "aliases": [ + "keyboard" + ] + }, + { + "emoji": "🔟", + "aliases": [ + "keycap_ten" + ] + }, + { + "emoji": "🪯", + "aliases": [ + "khanda" + ] + }, + { + "emoji": "🛴", + "aliases": [ + "kick_scooter" + ] + }, + { + "emoji": "👘", + "aliases": [ + "kimono" + ] + }, + { + "emoji": "🇰🇮", + "aliases": [ + "kiribati" + ] + }, + { + "emoji": "💋", + "aliases": [ + "kiss" + ] + }, + { + "emoji": "😗", + "aliases": [ + "kissing" + ] + }, + { + "emoji": "😽", + "aliases": [ + "kissing_cat" + ] + }, + { + "emoji": "😚", + "aliases": [ + "kissing_closed_eyes" + ] + }, + { + "emoji": "😘", + "aliases": [ + "kissing_heart" + ] + }, + { + "emoji": "😙", + "aliases": [ + "kissing_smiling_eyes" + ] + }, + { + "emoji": "🪁", + "aliases": [ + "kite" + ] + }, + { + "emoji": "🥝", + "aliases": [ + "kiwi_fruit" + ] + }, + { + "emoji": "🧎‍♂️", + "aliases": [ + "kneeling_man" + ] + }, + { + "emoji": "🧎", + "aliases": [ + "kneeling_person" + ] + }, + { + "emoji": "🧎‍♀️", + "aliases": [ + "kneeling_woman" + ] + }, + { + "emoji": "🪢", + "aliases": [ + "knot" + ] + }, + { + "emoji": "🐨", + "aliases": [ + "koala" + ] + }, + { + "emoji": "🈁", + "aliases": [ + "koko" + ] + }, + { + "emoji": "🇽🇰", + "aliases": [ + "kosovo" + ] + }, + { + "emoji": "🇰🇷", + "aliases": [ + "kr" + ] + }, + { + "emoji": "🇰🇼", + "aliases": [ + "kuwait" + ] + }, + { + "emoji": "🇰🇬", + "aliases": [ + "kyrgyzstan" + ] + }, + { + "emoji": "🥼", + "aliases": [ + "lab_coat" + ] + }, + { + "emoji": "🏷️", + "aliases": [ + "label" + ] + }, + { + "emoji": "🥍", + "aliases": [ + "lacrosse" + ] + }, + { + "emoji": "🪜", + "aliases": [ + "ladder" + ] + }, + { + "emoji": "🐞", + "aliases": [ + "lady_beetle" + ] + }, + { + "emoji": "🇱🇦", + "aliases": [ + "laos" + ] + }, + { + "emoji": "🔵", + "aliases": [ + "large_blue_circle" + ] + }, + { + "emoji": "🔷", + "aliases": [ + "large_blue_diamond" + ] + }, + { + "emoji": "🔶", + "aliases": [ + "large_orange_diamond" + ] + }, + { + "emoji": "🌗", + "aliases": [ + "last_quarter_moon" + ] + }, + { + "emoji": "🌜", + "aliases": [ + "last_quarter_moon_with_face" + ] + }, + { + "emoji": "✝️", + "aliases": [ + "latin_cross" + ] + }, + { + "emoji": "🇱🇻", + "aliases": [ + "latvia" + ] + }, + { + "emoji": "😆", + "aliases": [ + "laughing", + "satisfied", + "laugh" + ] + }, + { + "emoji": "🪾", + "aliases": [ + "leafless_tree" + ] + }, + { + "emoji": "🥬", + "aliases": [ + "leafy_green" + ] + }, + { + "emoji": "🍃", + "aliases": [ + "leaves" + ] + }, + { + "emoji": "🇱🇧", + "aliases": [ + "lebanon" + ] + }, + { + "emoji": "📒", + "aliases": [ + "ledger" + ] + }, + { + "emoji": "🛅", + "aliases": [ + "left_luggage" + ] + }, + { + "emoji": "↔️", + "aliases": [ + "left_right_arrow" + ] + }, + { + "emoji": "🗨️", + "aliases": [ + "left_speech_bubble" + ] + }, + { + "emoji": "↩️", + "aliases": [ + "leftwards_arrow_with_hook" + ] + }, + { + "emoji": "🫲", + "aliases": [ + "leftwards_hand" + ] + }, + { + "emoji": "🫷", + "aliases": [ + "leftwards_pushing_hand" + ] + }, + { + "emoji": "🦵", + "aliases": [ + "leg" + ] + }, + { + "emoji": "🍋", + "aliases": [ + "lemon" + ] + }, + { + "emoji": "♌", + "aliases": [ + "leo" + ] + }, + { + "emoji": "🐆", + "aliases": [ + "leopard" + ] + }, + { + "emoji": "🇱🇸", + "aliases": [ + "lesotho" + ] + }, + { + "emoji": "🎚️", + "aliases": [ + "level_slider" + ] + }, + { + "emoji": "🇱🇷", + "aliases": [ + "liberia" + ] + }, + { + "emoji": "♎", + "aliases": [ + "libra" + ] + }, + { + "emoji": "🇱🇾", + "aliases": [ + "libya" + ] + }, + { + "emoji": "🇱🇮", + "aliases": [ + "liechtenstein" + ] + }, + { + "emoji": "🩵", + "aliases": [ + "light_blue_heart" + ] + }, + { + "emoji": "🚈", + "aliases": [ + "light_rail" + ] + }, + { + "emoji": "🍋‍🟩", + "aliases": [ + "lime" + ] + }, + { + "emoji": "🔗", + "aliases": [ + "link" + ] + }, + { + "emoji": "🦁", + "aliases": [ + "lion" + ] + }, + { + "emoji": "👄", + "aliases": [ + "lips" + ] + }, + { + "emoji": "💄", + "aliases": [ + "lipstick" + ] + }, + { + "emoji": "🇱🇹", + "aliases": [ + "lithuania" + ] + }, + { + "emoji": "🦎", + "aliases": [ + "lizard" + ] + }, + { + "emoji": "🦙", + "aliases": [ + "llama" + ] + }, + { + "emoji": "🦞", + "aliases": [ + "lobster" + ] + }, + { + "emoji": "🔒", + "aliases": [ + "lock" + ] + }, + { + "emoji": "🔏", + "aliases": [ + "lock_with_ink_pen" + ] + }, + { + "emoji": "🍭", + "aliases": [ + "lollipop" + ] + }, + { + "emoji": "🪘", + "aliases": [ + "long_drum" + ] + }, + { + "emoji": "➿", + "aliases": [ + "loop" + ] + }, + { + "emoji": "🧴", + "aliases": [ + "lotion_bottle" + ] + }, + { + "emoji": "🪷", + "aliases": [ + "lotus" + ] + }, + { + "emoji": "🧘", + "aliases": [ + "lotus_position" + ] + }, + { + "emoji": "🧘‍♂️", + "aliases": [ + "lotus_position_man" + ] + }, + { + "emoji": "🧘‍♀️", + "aliases": [ + "lotus_position_woman" + ] + }, + { + "emoji": "🔊", + "aliases": [ + "loud_sound" + ] + }, + { + "emoji": "📢", + "aliases": [ + "loudspeaker" + ] + }, + { + "emoji": "🏩", + "aliases": [ + "love_hotel" + ] + }, + { + "emoji": "💌", + "aliases": [ + "love_letter" + ] + }, + { + "emoji": "🤟", + "aliases": [ + "love_you_gesture" + ] + }, + { + "emoji": "🪫", + "aliases": [ + "low_battery" + ] + }, + { + "emoji": "🔅", + "aliases": [ + "low_brightness" + ] + }, + { + "emoji": "🧳", + "aliases": [ + "luggage" + ] + }, + { + "emoji": "🫁", + "aliases": [ + "lungs" + ] + }, + { + "emoji": "🇱🇺", + "aliases": [ + "luxembourg" + ] + }, + { + "emoji": "🤥", + "aliases": [ + "lying_face" + ] + }, + { + "emoji": "Ⓜ️", + "aliases": [ + "m" + ] + }, + { + "emoji": "🇲🇴", + "aliases": [ + "macau" + ] + }, + { + "emoji": "🇲🇰", + "aliases": [ + "macedonia" + ] + }, + { + "emoji": "🇲🇬", + "aliases": [ + "madagascar" + ] + }, + { + "emoji": "🔍", + "aliases": [ + "mag" + ] + }, + { + "emoji": "🔎", + "aliases": [ + "mag_right" + ] + }, + { + "emoji": "🧙", + "aliases": [ + "mage" + ] + }, + { + "emoji": "🧙‍♂️", + "aliases": [ + "mage_man" + ] + }, + { + "emoji": "🧙‍♀️", + "aliases": [ + "mage_woman" + ] + }, + { + "emoji": "🪄", + "aliases": [ + "magic_wand" + ] + }, + { + "emoji": "🧲", + "aliases": [ + "magnet" + ] + }, + { + "emoji": "🀄", + "aliases": [ + "mahjong" + ] + }, + { + "emoji": "📫", + "aliases": [ + "mailbox" + ] + }, + { + "emoji": "📪", + "aliases": [ + "mailbox_closed" + ] + }, + { + "emoji": "📬", + "aliases": [ + "mailbox_with_mail" + ] + }, + { + "emoji": "📭", + "aliases": [ + "mailbox_with_no_mail" + ] + }, + { + "emoji": "🇲🇼", + "aliases": [ + "malawi" + ] + }, + { + "emoji": "🇲🇾", + "aliases": [ + "malaysia" + ] + }, + { + "emoji": "🇲🇻", + "aliases": [ + "maldives" + ] + }, + { + "emoji": "🕵️‍♂️", + "aliases": [ + "male_detective" + ] + }, + { + "emoji": "♂️", + "aliases": [ + "male_sign" + ] + }, + { + "emoji": "🇲🇱", + "aliases": [ + "mali" + ] + }, + { + "emoji": "🇲🇹", + "aliases": [ + "malta" + ] + }, + { + "emoji": "🦣", + "aliases": [ + "mammoth" + ] + }, + { + "emoji": "👨", + "aliases": [ + "man" + ] + }, + { + "emoji": "👨‍🎨", + "aliases": [ + "man_artist" + ] + }, + { + "emoji": "👨‍🚀", + "aliases": [ + "man_astronaut" + ] + }, + { + "emoji": "🧔‍♂️", + "aliases": [ + "man_beard" + ] + }, + { + "emoji": "🤸‍♂️", + "aliases": [ + "man_cartwheeling" + ] + }, + { + "emoji": "👨‍🍳", + "aliases": [ + "man_cook" + ] + }, + { + "emoji": "🕺", + "aliases": [ + "man_dancing" + ] + }, + { + "emoji": "🤦‍♂️", + "aliases": [ + "man_facepalming" + ] + }, + { + "emoji": "👨‍🏭", + "aliases": [ + "man_factory_worker" + ] + }, + { + "emoji": "👨‍🌾", + "aliases": [ + "man_farmer" + ] + }, + { + "emoji": "👨‍🍼", + "aliases": [ + "man_feeding_baby" + ] + }, + { + "emoji": "👨‍🚒", + "aliases": [ + "man_firefighter" + ] + }, + { + "emoji": "👨‍⚕️", + "aliases": [ + "man_health_worker" + ] + }, + { + "emoji": "👨‍🦽", + "aliases": [ + "man_in_manual_wheelchair" + ] + }, + { + "emoji": "👨‍🦽‍➡️", + "aliases": [ + "man_in_manual_wheelchair_facing_right" + ] + }, + { + "emoji": "👨‍🦼", + "aliases": [ + "man_in_motorized_wheelchair" + ] + }, + { + "emoji": "👨‍🦼‍➡️", + "aliases": [ + "man_in_motorized_wheelchair_facing_right" + ] + }, + { + "emoji": "🤵‍♂️", + "aliases": [ + "man_in_tuxedo" + ] + }, + { + "emoji": "👨‍⚖️", + "aliases": [ + "man_judge" + ] + }, + { + "emoji": "🤹‍♂️", + "aliases": [ + "man_juggling" + ] + }, + { + "emoji": "🧎‍♂️‍➡️", + "aliases": [ + "man_kneeling_facing_right" + ] + }, + { + "emoji": "👨‍🔧", + "aliases": [ + "man_mechanic" + ] + }, + { + "emoji": "👨‍💼", + "aliases": [ + "man_office_worker" + ] + }, + { + "emoji": "👨‍✈️", + "aliases": [ + "man_pilot" + ] + }, + { + "emoji": "🤾‍♂️", + "aliases": [ + "man_playing_handball" + ] + }, + { + "emoji": "🤽‍♂️", + "aliases": [ + "man_playing_water_polo" + ] + }, + { + "emoji": "🏃‍♂️‍➡️", + "aliases": [ + "man_running_facing_right" + ] + }, + { + "emoji": "👨‍🔬", + "aliases": [ + "man_scientist" + ] + }, + { + "emoji": "🤷‍♂️", + "aliases": [ + "man_shrugging" + ] + }, + { + "emoji": "👨‍🎤", + "aliases": [ + "man_singer" + ] + }, + { + "emoji": "👨‍🎓", + "aliases": [ + "man_student" + ] + }, + { + "emoji": "👨‍🏫", + "aliases": [ + "man_teacher" + ] + }, + { + "emoji": "👨‍💻", + "aliases": [ + "man_technologist" + ] + }, + { + "emoji": "🚶‍♂️‍➡️", + "aliases": [ + "man_walking_facing_right" + ] + }, + { + "emoji": "👲", + "aliases": [ + "man_with_gua_pi_mao" + ] + }, + { + "emoji": "👨‍🦯", + "aliases": [ + "man_with_probing_cane" + ] + }, + { + "emoji": "👳‍♂️", + "aliases": [ + "man_with_turban" + ] + }, + { + "emoji": "👰‍♂️", + "aliases": [ + "man_with_veil" + ] + }, + { + "emoji": "👨‍🦯‍➡️", + "aliases": [ + "man_with_white_cane_facing_right" + ] + }, + { + "emoji": "🥭", + "aliases": [ + "mango" + ] + }, + { + "emoji": "👞", + "aliases": [ + "mans_shoe", + "shoe" + ] + }, + { + "emoji": "🕰️", + "aliases": [ + "mantelpiece_clock" + ] + }, + { + "emoji": "🦽", + "aliases": [ + "manual_wheelchair" + ] + }, + { + "emoji": "🍁", + "aliases": [ + "maple_leaf" + ] + }, + { + "emoji": "🪇", + "aliases": [ + "maracas" + ] + }, + { + "emoji": "🇲🇭", + "aliases": [ + "marshall_islands" + ] + }, + { + "emoji": "🥋", + "aliases": [ + "martial_arts_uniform" + ] + }, + { + "emoji": "🇲🇶", + "aliases": [ + "martinique" + ] + }, + { + "emoji": "😷", + "aliases": [ + "mask" + ] + }, + { + "emoji": "💆", + "aliases": [ + "massage" + ] + }, + { + "emoji": "💆‍♂️", + "aliases": [ + "massage_man" + ] + }, + { + "emoji": "💆‍♀️", + "aliases": [ + "massage_woman" + ] + }, + { + "emoji": "🧉", + "aliases": [ + "mate" + ] + }, + { + "emoji": "🇲🇷", + "aliases": [ + "mauritania" + ] + }, + { + "emoji": "🇲🇺", + "aliases": [ + "mauritius" + ] + }, + { + "emoji": "🇾🇹", + "aliases": [ + "mayotte" + ] + }, + { + "emoji": "🍖", + "aliases": [ + "meat_on_bone" + ] + }, + { + "emoji": "🧑‍🔧", + "aliases": [ + "mechanic" + ] + }, + { + "emoji": "🦾", + "aliases": [ + "mechanical_arm" + ] + }, + { + "emoji": "🦿", + "aliases": [ + "mechanical_leg" + ] + }, + { + "emoji": "🎖️", + "aliases": [ + "medal_military" + ] + }, + { + "emoji": "🏅", + "aliases": [ + "medal_sports" + ] + }, + { + "emoji": "⚕️", + "aliases": [ + "medical_symbol" + ] + }, + { + "emoji": "📣", + "aliases": [ + "mega" + ] + }, + { + "emoji": "🍈", + "aliases": [ + "melon" + ] + }, + { + "emoji": "🫠", + "aliases": [ + "melting_face" + ] + }, + { + "emoji": "📝", + "aliases": [ + "memo", + "pencil" + ] + }, + { + "emoji": "🤼‍♂️", + "aliases": [ + "men_wrestling" + ] + }, + { + "emoji": "❤️‍🩹", + "aliases": [ + "mending_heart" + ] + }, + { + "emoji": "🕎", + "aliases": [ + "menorah" + ] + }, + { + "emoji": "🚹", + "aliases": [ + "mens" + ] + }, + { + "emoji": "🧜‍♀️", + "aliases": [ + "mermaid" + ] + }, + { + "emoji": "🧜‍♂️", + "aliases": [ + "merman" + ] + }, + { + "emoji": "🧜", + "aliases": [ + "merperson" + ] + }, + { + "emoji": "🤘", + "aliases": [ + "metal" + ] + }, + { + "emoji": "🚇", + "aliases": [ + "metro" + ] + }, + { + "emoji": "🇲🇽", + "aliases": [ + "mexico" + ] + }, + { + "emoji": "🦠", + "aliases": [ + "microbe" + ] + }, + { + "emoji": "🇫🇲", + "aliases": [ + "micronesia" + ] + }, + { + "emoji": "🎤", + "aliases": [ + "microphone" + ] + }, + { + "emoji": "🔬", + "aliases": [ + "microscope" + ] + }, + { + "emoji": "🖕", + "aliases": [ + "middle_finger", + "fu" + ] + }, + { + "emoji": "🪖", + "aliases": [ + "military_helmet" + ] + }, + { + "emoji": "🥛", + "aliases": [ + "milk_glass" + ] + }, + { + "emoji": "🌌", + "aliases": [ + "milky_way" + ] + }, + { + "emoji": "🚐", + "aliases": [ + "minibus" + ] + }, + { + "emoji": "💽", + "aliases": [ + "minidisc" + ] + }, + { + "emoji": "🪞", + "aliases": [ + "mirror" + ] + }, + { + "emoji": "🪩", + "aliases": [ + "mirror_ball" + ] + }, + { + "emoji": "📴", + "aliases": [ + "mobile_phone_off" + ] + }, + { + "emoji": "🇲🇩", + "aliases": [ + "moldova" + ] + }, + { + "emoji": "🇲🇨", + "aliases": [ + "monaco" + ] + }, + { + "emoji": "🤑", + "aliases": [ + "money_mouth_face" + ] + }, + { + "emoji": "💸", + "aliases": [ + "money_with_wings" + ] + }, + { + "emoji": "💰", + "aliases": [ + "moneybag" + ] + }, + { + "emoji": "🇲🇳", + "aliases": [ + "mongolia" + ] + }, + { + "emoji": "🐒", + "aliases": [ + "monkey" + ] + }, + { + "emoji": "🐵", + "aliases": [ + "monkey_face" + ] + }, + { + "emoji": "🧐", + "aliases": [ + "monocle_face" + ] + }, + { + "emoji": "🚝", + "aliases": [ + "monorail" + ] + }, + { + "emoji": "🇲🇪", + "aliases": [ + "montenegro" + ] + }, + { + "emoji": "🇲🇸", + "aliases": [ + "montserrat" + ] + }, + { + "emoji": "🌔", + "aliases": [ + "moon", + "waxing_gibbous_moon" + ] + }, + { + "emoji": "🥮", + "aliases": [ + "moon_cake" + ] + }, + { + "emoji": "🫎", + "aliases": [ + "moose" + ] + }, + { + "emoji": "🇲🇦", + "aliases": [ + "morocco" + ] + }, + { + "emoji": "🎓", + "aliases": [ + "mortar_board" + ] + }, + { + "emoji": "🕌", + "aliases": [ + "mosque" + ] + }, + { + "emoji": "🦟", + "aliases": [ + "mosquito" + ] + }, + { + "emoji": "🛥️", + "aliases": [ + "motor_boat" + ] + }, + { + "emoji": "🛵", + "aliases": [ + "motor_scooter" + ] + }, + { + "emoji": "🏍️", + "aliases": [ + "motorcycle" + ] + }, + { + "emoji": "🦼", + "aliases": [ + "motorized_wheelchair" + ] + }, + { + "emoji": "🛣️", + "aliases": [ + "motorway" + ] + }, + { + "emoji": "🗻", + "aliases": [ + "mount_fuji" + ] + }, + { + "emoji": "⛰️", + "aliases": [ + "mountain" + ] + }, + { + "emoji": "🚵", + "aliases": [ + "mountain_bicyclist" + ] + }, + { + "emoji": "🚵‍♂️", + "aliases": [ + "mountain_biking_man" + ] + }, + { + "emoji": "🚵‍♀️", + "aliases": [ + "mountain_biking_woman" + ] + }, + { + "emoji": "🚠", + "aliases": [ + "mountain_cableway" + ] + }, + { + "emoji": "🚞", + "aliases": [ + "mountain_railway" + ] + }, + { + "emoji": "🏔️", + "aliases": [ + "mountain_snow" + ] + }, + { + "emoji": "🐭", + "aliases": [ + "mouse" + ] + }, + { + "emoji": "🐁", + "aliases": [ + "mouse2" + ] + }, + { + "emoji": "🪤", + "aliases": [ + "mouse_trap" + ] + }, + { + "emoji": "🎥", + "aliases": [ + "movie_camera" + ] + }, + { + "emoji": "🗿", + "aliases": [ + "moyai" + ] + }, + { + "emoji": "🇲🇿", + "aliases": [ + "mozambique" + ] + }, + { + "emoji": "🤶", + "aliases": [ + "mrs_claus" + ] + }, + { + "emoji": "💪", + "aliases": [ + "muscle" + ] + }, + { + "emoji": "🍄", + "aliases": [ + "mushroom" + ] + }, + { + "emoji": "🎹", + "aliases": [ + "musical_keyboard" + ] + }, + { + "emoji": "🎵", + "aliases": [ + "musical_note" + ] + }, + { + "emoji": "🎼", + "aliases": [ + "musical_score" + ] + }, + { + "emoji": "🔇", + "aliases": [ + "mute" + ] + }, + { + "emoji": "🧑‍🎄", + "aliases": [ + "mx_claus" + ] + }, + { + "emoji": "🇲🇲", + "aliases": [ + "myanmar" + ] + }, + { + "emoji": "💅", + "aliases": [ + "nail_care" + ] + }, + { + "emoji": "📛", + "aliases": [ + "name_badge" + ] + }, + { + "emoji": "🇳🇦", + "aliases": [ + "namibia" + ] + }, + { + "emoji": "🏞️", + "aliases": [ + "national_park" + ] + }, + { + "emoji": "🇳🇷", + "aliases": [ + "nauru" + ] + }, + { + "emoji": "🤢", + "aliases": [ + "nauseated_face" + ] + }, + { + "emoji": "🧿", + "aliases": [ + "nazar_amulet" + ] + }, + { + "emoji": "👔", + "aliases": [ + "necktie" + ] + }, + { + "emoji": "❎", + "aliases": [ + "negative_squared_cross_mark" + ] + }, + { + "emoji": "🇳🇵", + "aliases": [ + "nepal" + ] + }, + { + "emoji": "🤓", + "aliases": [ + "nerd_face" + ] + }, + { + "emoji": "🪺", + "aliases": [ + "nest_with_eggs" + ] + }, + { + "emoji": "🪆", + "aliases": [ + "nesting_dolls" + ] + }, + { + "emoji": "🇳🇱", + "aliases": [ + "netherlands" + ] + }, + { + "emoji": "😐", + "aliases": [ + "neutral_face" + ] + }, + { + "emoji": "🆕", + "aliases": [ + "new" + ] + }, + { + "emoji": "🇳🇨", + "aliases": [ + "new_caledonia" + ] + }, + { + "emoji": "🌑", + "aliases": [ + "new_moon" + ] + }, + { + "emoji": "🌚", + "aliases": [ + "new_moon_with_face" + ] + }, + { + "emoji": "🇳🇿", + "aliases": [ + "new_zealand" + ] + }, + { + "emoji": "📰", + "aliases": [ + "newspaper" + ] + }, + { + "emoji": "🗞️", + "aliases": [ + "newspaper_roll" + ] + }, + { + "emoji": "⏭️", + "aliases": [ + "next_track_button" + ] + }, + { + "emoji": "🆖", + "aliases": [ + "ng" + ] + }, + { + "emoji": "🇳🇮", + "aliases": [ + "nicaragua" + ] + }, + { + "emoji": "🇳🇪", + "aliases": [ + "niger" + ] + }, + { + "emoji": "🇳🇬", + "aliases": [ + "nigeria" + ] + }, + { + "emoji": "🌃", + "aliases": [ + "night_with_stars" + ] + }, + { + "emoji": "9️⃣", + "aliases": [ + "nine" + ] + }, + { + "emoji": "🥷", + "aliases": [ + "ninja" + ] + }, + { + "emoji": "🇳🇺", + "aliases": [ + "niue" + ] + }, + { + "emoji": "🔕", + "aliases": [ + "no_bell" + ] + }, + { + "emoji": "🚳", + "aliases": [ + "no_bicycles" + ] + }, + { + "emoji": "⛔", + "aliases": [ + "no_entry" + ] + }, + { + "emoji": "🚫", + "aliases": [ + "no_entry_sign" + ] + }, + { + "emoji": "🙅", + "aliases": [ + "no_good" + ] + }, + { + "emoji": "🙅‍♂️", + "aliases": [ + "no_good_man", + "ng_man" + ] + }, + { + "emoji": "🙅‍♀️", + "aliases": [ + "no_good_woman", + "ng_woman" + ] + }, + { + "emoji": "📵", + "aliases": [ + "no_mobile_phones" + ] + }, + { + "emoji": "😶", + "aliases": [ + "no_mouth" + ] + }, + { + "emoji": "🚷", + "aliases": [ + "no_pedestrians" + ] + }, + { + "emoji": "🚭", + "aliases": [ + "no_smoking" + ] + }, + { + "emoji": "🚱", + "aliases": [ + "non-potable_water" + ] + }, + { + "emoji": "🇳🇫", + "aliases": [ + "norfolk_island" + ] + }, + { + "emoji": "🇰🇵", + "aliases": [ + "north_korea" + ] + }, + { + "emoji": "🇲🇵", + "aliases": [ + "northern_mariana_islands" + ] + }, + { + "emoji": "🇳🇴", + "aliases": [ + "norway" + ] + }, + { + "emoji": "👃", + "aliases": [ + "nose" + ] + }, + { + "emoji": "📓", + "aliases": [ + "notebook" + ] + }, + { + "emoji": "📔", + "aliases": [ + "notebook_with_decorative_cover" + ] + }, + { + "emoji": "🎶", + "aliases": [ + "notes" + ] + }, + { + "emoji": "🔩", + "aliases": [ + "nut_and_bolt" + ] + }, + { + "emoji": "⭕", + "aliases": [ + "o" + ] + }, + { + "emoji": "🅾️", + "aliases": [ + "o2" + ] + }, + { + "emoji": "🌊", + "aliases": [ + "ocean" + ] + }, + { + "emoji": "🐙", + "aliases": [ + "octopus" + ] + }, + { + "emoji": "🍢", + "aliases": [ + "oden" + ] + }, + { + "emoji": "🏢", + "aliases": [ + "office" + ] + }, + { + "emoji": "🧑‍💼", + "aliases": [ + "office_worker" + ] + }, + { + "emoji": "🛢️", + "aliases": [ + "oil_drum" + ] + }, + { + "emoji": "🆗", + "aliases": [ + "ok" + ] + }, + { + "emoji": "👌", + "aliases": [ + "ok_hand" + ] + }, + { + "emoji": "🙆‍♂️", + "aliases": [ + "ok_man" + ] + }, + { + "emoji": "🙆", + "aliases": [ + "ok_person" + ] + }, + { + "emoji": "🙆‍♀️", + "aliases": [ + "ok_woman" + ] + }, + { + "emoji": "🗝️", + "aliases": [ + "old_key" + ] + }, + { + "emoji": "🧓", + "aliases": [ + "older_adult" + ] + }, + { + "emoji": "👴", + "aliases": [ + "older_man" + ] + }, + { + "emoji": "👵", + "aliases": [ + "older_woman" + ] + }, + { + "emoji": "🫒", + "aliases": [ + "olive" + ] + }, + { + "emoji": "🕉️", + "aliases": [ + "om" + ] + }, + { + "emoji": "🇴🇲", + "aliases": [ + "oman" + ] + }, + { + "emoji": "🔛", + "aliases": [ + "on" + ] + }, + { + "emoji": "🚘", + "aliases": [ + "oncoming_automobile" + ] + }, + { + "emoji": "🚍", + "aliases": [ + "oncoming_bus" + ] + }, + { + "emoji": "🚔", + "aliases": [ + "oncoming_police_car" + ] + }, + { + "emoji": "🚖", + "aliases": [ + "oncoming_taxi" + ] + }, + { + "emoji": "1️⃣", + "aliases": [ + "one" + ] + }, + { + "emoji": "🩱", + "aliases": [ + "one_piece_swimsuit" + ] + }, + { + "emoji": "🧅", + "aliases": [ + "onion" + ] + }, + { + "emoji": "📂", + "aliases": [ + "open_file_folder" + ] + }, + { + "emoji": "👐", + "aliases": [ + "open_hands" + ] + }, + { + "emoji": "😮", + "aliases": [ + "open_mouth" + ] + }, + { + "emoji": "☂️", + "aliases": [ + "open_umbrella" + ] + }, + { + "emoji": "⛎", + "aliases": [ + "ophiuchus" + ] + }, + { + "emoji": "📙", + "aliases": [ + "orange_book" + ] + }, + { + "emoji": "🟠", + "aliases": [ + "orange_circle" + ] + }, + { + "emoji": "🧡", + "aliases": [ + "orange_heart" + ] + }, + { + "emoji": "🟧", + "aliases": [ + "orange_square" + ] + }, + { + "emoji": "🦧", + "aliases": [ + "orangutan" + ] + }, + { + "emoji": "☦️", + "aliases": [ + "orthodox_cross" + ] + }, + { + "emoji": "🦦", + "aliases": [ + "otter" + ] + }, + { + "emoji": "📤", + "aliases": [ + "outbox_tray" + ] + }, + { + "emoji": "🦉", + "aliases": [ + "owl" + ] + }, + { + "emoji": "🐂", + "aliases": [ + "ox" + ] + }, + { + "emoji": "🦪", + "aliases": [ + "oyster" + ] + }, + { + "emoji": "📦", + "aliases": [ + "package" + ] + }, + { + "emoji": "📄", + "aliases": [ + "page_facing_up" + ] + }, + { + "emoji": "📃", + "aliases": [ + "page_with_curl" + ] + }, + { + "emoji": "📟", + "aliases": [ + "pager" + ] + }, + { + "emoji": "🖌️", + "aliases": [ + "paintbrush" + ] + }, + { + "emoji": "🇵🇰", + "aliases": [ + "pakistan" + ] + }, + { + "emoji": "🇵🇼", + "aliases": [ + "palau" + ] + }, + { + "emoji": "🇵🇸", + "aliases": [ + "palestinian_territories" + ] + }, + { + "emoji": "🫳", + "aliases": [ + "palm_down_hand" + ] + }, + { + "emoji": "🌴", + "aliases": [ + "palm_tree" + ] + }, + { + "emoji": "🫴", + "aliases": [ + "palm_up_hand" + ] + }, + { + "emoji": "🤲", + "aliases": [ + "palms_up_together" + ] + }, + { + "emoji": "🇵🇦", + "aliases": [ + "panama" + ] + }, + { + "emoji": "🥞", + "aliases": [ + "pancakes" + ] + }, + { + "emoji": "🐼", + "aliases": [ + "panda_face" + ] + }, + { + "emoji": "📎", + "aliases": [ + "paperclip" + ] + }, + { + "emoji": "🖇️", + "aliases": [ + "paperclips" + ] + }, + { + "emoji": "🇵🇬", + "aliases": [ + "papua_new_guinea" + ] + }, + { + "emoji": "🪂", + "aliases": [ + "parachute" + ] + }, + { + "emoji": "🇵🇾", + "aliases": [ + "paraguay" + ] + }, + { + "emoji": "⛱️", + "aliases": [ + "parasol_on_ground" + ] + }, + { + "emoji": "🅿️", + "aliases": [ + "parking" + ] + }, + { + "emoji": "🦜", + "aliases": [ + "parrot" + ] + }, + { + "emoji": "〽️", + "aliases": [ + "part_alternation_mark" + ] + }, + { + "emoji": "⛅", + "aliases": [ + "partly_sunny" + ] + }, + { + "emoji": "🥳", + "aliases": [ + "partying_face" + ] + }, + { + "emoji": "🛳️", + "aliases": [ + "passenger_ship" + ] + }, + { + "emoji": "🛂", + "aliases": [ + "passport_control" + ] + }, + { + "emoji": "⏸️", + "aliases": [ + "pause_button" + ] + }, + { + "emoji": "🫛", + "aliases": [ + "pea_pod" + ] + }, + { + "emoji": "☮️", + "aliases": [ + "peace_symbol" + ] + }, + { + "emoji": "🍑", + "aliases": [ + "peach" + ] + }, + { + "emoji": "🦚", + "aliases": [ + "peacock" + ] + }, + { + "emoji": "🥜", + "aliases": [ + "peanuts" + ] + }, + { + "emoji": "🍐", + "aliases": [ + "pear" + ] + }, + { + "emoji": "🖊️", + "aliases": [ + "pen" + ] + }, + { + "emoji": "✏️", + "aliases": [ + "pencil2" + ] + }, + { + "emoji": "🐧", + "aliases": [ + "penguin" + ] + }, + { + "emoji": "😔", + "aliases": [ + "pensive" + ] + }, + { + "emoji": "🧑‍🤝‍🧑", + "aliases": [ + "people_holding_hands" + ] + }, + { + "emoji": "🫂", + "aliases": [ + "people_hugging" + ] + }, + { + "emoji": "🎭", + "aliases": [ + "performing_arts" + ] + }, + { + "emoji": "😣", + "aliases": [ + "persevere" + ] + }, + { + "emoji": "🧑‍🦲", + "aliases": [ + "person_bald" + ] + }, + { + "emoji": "🧑‍🦱", + "aliases": [ + "person_curly_hair" + ] + }, + { + "emoji": "🧑‍🍼", + "aliases": [ + "person_feeding_baby" + ] + }, + { + "emoji": "🤺", + "aliases": [ + "person_fencing" + ] + }, + { + "emoji": "🧑‍🦽", + "aliases": [ + "person_in_manual_wheelchair" + ] + }, + { + "emoji": "🧑‍🦽‍➡️", + "aliases": [ + "person_in_manual_wheelchair_facing_right" + ] + }, + { + "emoji": "🧑‍🦼", + "aliases": [ + "person_in_motorized_wheelchair" + ] + }, + { + "emoji": "🧑‍🦼‍➡️", + "aliases": [ + "person_in_motorized_wheelchair_facing_right" + ] + }, + { + "emoji": "🤵", + "aliases": [ + "person_in_tuxedo" + ] + }, + { + "emoji": "🧎‍➡️", + "aliases": [ + "person_kneeling_facing_right" + ] + }, + { + "emoji": "🧑‍🦰", + "aliases": [ + "person_red_hair" + ] + }, + { + "emoji": "🏃‍➡️", + "aliases": [ + "person_running_facing_right" + ] + }, + { + "emoji": "🚶‍➡️", + "aliases": [ + "person_walking_facing_right" + ] + }, + { + "emoji": "🧑‍🦳", + "aliases": [ + "person_white_hair" + ] + }, + { + "emoji": "🫅", + "aliases": [ + "person_with_crown" + ] + }, + { + "emoji": "🧑‍🦯", + "aliases": [ + "person_with_probing_cane" + ] + }, + { + "emoji": "👳", + "aliases": [ + "person_with_turban" + ] + }, + { + "emoji": "👰", + "aliases": [ + "person_with_veil" + ] + }, + { + "emoji": "🧑‍🦯‍➡️", + "aliases": [ + "person_with_white_cane_facing_right" + ] + }, + { + "emoji": "🇵🇪", + "aliases": [ + "peru" + ] + }, + { + "emoji": "🧫", + "aliases": [ + "petri_dish" + ] + }, + { + "emoji": "🇵🇭", + "aliases": [ + "philippines" + ] + }, + { + "emoji": "🐦‍🔥", + "aliases": [ + "phoenix" + ] + }, + { + "emoji": "☎️", + "aliases": [ + "phone", + "telephone" + ] + }, + { + "emoji": "⛏️", + "aliases": [ + "pick" + ] + }, + { + "emoji": "🛻", + "aliases": [ + "pickup_truck" + ] + }, + { + "emoji": "🥧", + "aliases": [ + "pie" + ] + }, + { + "emoji": "🐷", + "aliases": [ + "pig" + ] + }, + { + "emoji": "🐖", + "aliases": [ + "pig2" + ] + }, + { + "emoji": "🐽", + "aliases": [ + "pig_nose" + ] + }, + { + "emoji": "💊", + "aliases": [ + "pill" + ] + }, + { + "emoji": "🧑‍✈️", + "aliases": [ + "pilot" + ] + }, + { + "emoji": "🪅", + "aliases": [ + "pinata" + ] + }, + { + "emoji": "🤌", + "aliases": [ + "pinched_fingers" + ] + }, + { + "emoji": "🤏", + "aliases": [ + "pinching_hand" + ] + }, + { + "emoji": "🍍", + "aliases": [ + "pineapple" + ] + }, + { + "emoji": "🏓", + "aliases": [ + "ping_pong" + ] + }, + { + "emoji": "🩷", + "aliases": [ + "pink_heart" + ] + }, + { + "emoji": "🏴‍☠️", + "aliases": [ + "pirate_flag" + ] + }, + { + "emoji": "♓", + "aliases": [ + "pisces" + ] + }, + { + "emoji": "🇵🇳", + "aliases": [ + "pitcairn_islands" + ] + }, + { + "emoji": "🍕", + "aliases": [ + "pizza" + ] + }, + { + "emoji": "🪧", + "aliases": [ + "placard" + ] + }, + { + "emoji": "🛐", + "aliases": [ + "place_of_worship" + ] + }, + { + "emoji": "🍽️", + "aliases": [ + "plate_with_cutlery" + ] + }, + { + "emoji": "⏯️", + "aliases": [ + "play_or_pause_button" + ] + }, + { + "emoji": "🛝", + "aliases": [ + "playground_slide" + ] + }, + { + "emoji": "🥺", + "aliases": [ + "pleading_face" + ] + }, + { + "emoji": "🪠", + "aliases": [ + "plunger" + ] + }, + { + "emoji": "👇", + "aliases": [ + "point_down" + ] + }, + { + "emoji": "👈", + "aliases": [ + "point_left" + ] + }, + { + "emoji": "👉", + "aliases": [ + "point_right" + ] + }, + { + "emoji": "☝️", + "aliases": [ + "point_up" + ] + }, + { + "emoji": "👆", + "aliases": [ + "point_up_2" + ] + }, + { + "emoji": "🇵🇱", + "aliases": [ + "poland" + ] + }, + { + "emoji": "🐻‍❄️", + "aliases": [ + "polar_bear" + ] + }, + { + "emoji": "🚓", + "aliases": [ + "police_car" + ] + }, + { + "emoji": "👮", + "aliases": [ + "police_officer", + "cop" + ] + }, + { + "emoji": "👮‍♂️", + "aliases": [ + "policeman" + ] + }, + { + "emoji": "👮‍♀️", + "aliases": [ + "policewoman" + ] + }, + { + "emoji": "🐩", + "aliases": [ + "poodle" + ] + }, + { + "emoji": "🍿", + "aliases": [ + "popcorn" + ] + }, + { + "emoji": "🇵🇹", + "aliases": [ + "portugal" + ] + }, + { + "emoji": "🏣", + "aliases": [ + "post_office" + ] + }, + { + "emoji": "📯", + "aliases": [ + "postal_horn" + ] + }, + { + "emoji": "📮", + "aliases": [ + "postbox" + ] + }, + { + "emoji": "🚰", + "aliases": [ + "potable_water" + ] + }, + { + "emoji": "🥔", + "aliases": [ + "potato" + ] + }, + { + "emoji": "🪴", + "aliases": [ + "potted_plant" + ] + }, + { + "emoji": "👝", + "aliases": [ + "pouch" + ] + }, + { + "emoji": "🍗", + "aliases": [ + "poultry_leg" + ] + }, + { + "emoji": "💷", + "aliases": [ + "pound" + ] + }, + { + "emoji": "🫗", + "aliases": [ + "pouring_liquid" + ] + }, + { + "emoji": "😾", + "aliases": [ + "pouting_cat" + ] + }, + { + "emoji": "🙎", + "aliases": [ + "pouting_face" + ] + }, + { + "emoji": "🙎‍♂️", + "aliases": [ + "pouting_man" + ] + }, + { + "emoji": "🙎‍♀️", + "aliases": [ + "pouting_woman" + ] + }, + { + "emoji": "🙏", + "aliases": [ + "pray" + ] + }, + { + "emoji": "📿", + "aliases": [ + "prayer_beads" + ] + }, + { + "emoji": "🫃", + "aliases": [ + "pregnant_man" + ] + }, + { + "emoji": "🫄", + "aliases": [ + "pregnant_person" + ] + }, + { + "emoji": "🤰", + "aliases": [ + "pregnant_woman" + ] + }, + { + "emoji": "🥨", + "aliases": [ + "pretzel" + ] + }, + { + "emoji": "⏮️", + "aliases": [ + "previous_track_button" + ] + }, + { + "emoji": "🤴", + "aliases": [ + "prince" + ] + }, + { + "emoji": "👸", + "aliases": [ + "princess" + ] + }, + { + "emoji": "🖨️", + "aliases": [ + "printer" + ] + }, + { + "emoji": "🦯", + "aliases": [ + "probing_cane" + ] + }, + { + "emoji": "🇵🇷", + "aliases": [ + "puerto_rico" + ] + }, + { + "emoji": "🟣", + "aliases": [ + "purple_circle" + ] + }, + { + "emoji": "💜", + "aliases": [ + "purple_heart" + ] + }, + { + "emoji": "🟪", + "aliases": [ + "purple_square" + ] + }, + { + "emoji": "👛", + "aliases": [ + "purse" + ] + }, + { + "emoji": "📌", + "aliases": [ + "pushpin" + ] + }, + { + "emoji": "🚮", + "aliases": [ + "put_litter_in_its_place" + ] + }, + { + "emoji": "🇶🇦", + "aliases": [ + "qatar" + ] + }, + { + "emoji": "❓", + "aliases": [ + "question" + ] + }, + { + "emoji": "🐰", + "aliases": [ + "rabbit" + ] + }, + { + "emoji": "🐇", + "aliases": [ + "rabbit2" + ] + }, + { + "emoji": "🦝", + "aliases": [ + "raccoon" + ] + }, + { + "emoji": "🐎", + "aliases": [ + "racehorse" + ] + }, + { + "emoji": "🏎️", + "aliases": [ + "racing_car" + ] + }, + { + "emoji": "📻", + "aliases": [ + "radio" + ] + }, + { + "emoji": "🔘", + "aliases": [ + "radio_button" + ] + }, + { + "emoji": "☢️", + "aliases": [ + "radioactive" + ] + }, + { + "emoji": "😡", + "aliases": [ + "rage", + "pout" + ] + }, + { + "emoji": "🚃", + "aliases": [ + "railway_car" + ] + }, + { + "emoji": "🛤️", + "aliases": [ + "railway_track" + ] + }, + { + "emoji": "🌈", + "aliases": [ + "rainbow" + ] + }, + { + "emoji": "🏳️‍🌈", + "aliases": [ + "rainbow_flag" + ] + }, + { + "emoji": "🤚", + "aliases": [ + "raised_back_of_hand" + ] + }, + { + "emoji": "🤨", + "aliases": [ + "raised_eyebrow" + ] + }, + { + "emoji": "🖐️", + "aliases": [ + "raised_hand_with_fingers_splayed" + ] + }, + { + "emoji": "🙌", + "aliases": [ + "raised_hands" + ] + }, + { + "emoji": "🙋", + "aliases": [ + "raising_hand" + ] + }, + { + "emoji": "🙋‍♂️", + "aliases": [ + "raising_hand_man" + ] + }, + { + "emoji": "🙋‍♀️", + "aliases": [ + "raising_hand_woman" + ] + }, + { + "emoji": "🐏", + "aliases": [ + "ram" + ] + }, + { + "emoji": "🍜", + "aliases": [ + "ramen" + ] + }, + { + "emoji": "🐀", + "aliases": [ + "rat" + ] + }, + { + "emoji": "🪒", + "aliases": [ + "razor" + ] + }, + { + "emoji": "🧾", + "aliases": [ + "receipt" + ] + }, + { + "emoji": "⏺️", + "aliases": [ + "record_button" + ] + }, + { + "emoji": "♻️", + "aliases": [ + "recycle" + ] + }, + { + "emoji": "🔴", + "aliases": [ + "red_circle" + ] + }, + { + "emoji": "🧧", + "aliases": [ + "red_envelope" + ] + }, + { + "emoji": "👨‍🦰", + "aliases": [ + "red_haired_man" + ] + }, + { + "emoji": "👩‍🦰", + "aliases": [ + "red_haired_woman" + ] + }, + { + "emoji": "🟥", + "aliases": [ + "red_square" + ] + }, + { + "emoji": "®️", + "aliases": [ + "registered" + ] + }, + { + "emoji": "☺️", + "aliases": [ + "relaxed" + ] + }, + { + "emoji": "😌", + "aliases": [ + "relieved" + ] + }, + { + "emoji": "🎗️", + "aliases": [ + "reminder_ribbon" + ] + }, + { + "emoji": "🔁", + "aliases": [ + "repeat" + ] + }, + { + "emoji": "🔂", + "aliases": [ + "repeat_one" + ] + }, + { + "emoji": "⛑️", + "aliases": [ + "rescue_worker_helmet" + ] + }, + { + "emoji": "🚻", + "aliases": [ + "restroom" + ] + }, + { + "emoji": "🇷🇪", + "aliases": [ + "reunion" + ] + }, + { + "emoji": "💞", + "aliases": [ + "revolving_hearts" + ] + }, + { + "emoji": "⏪", + "aliases": [ + "rewind" + ] + }, + { + "emoji": "🦏", + "aliases": [ + "rhinoceros" + ] + }, + { + "emoji": "🎀", + "aliases": [ + "ribbon" + ] + }, + { + "emoji": "🍚", + "aliases": [ + "rice" + ] + }, + { + "emoji": "🍙", + "aliases": [ + "rice_ball" + ] + }, + { + "emoji": "🍘", + "aliases": [ + "rice_cracker" + ] + }, + { + "emoji": "🎑", + "aliases": [ + "rice_scene" + ] + }, + { + "emoji": "🗯️", + "aliases": [ + "right_anger_bubble" + ] + }, + { + "emoji": "🫱", + "aliases": [ + "rightwards_hand" + ] + }, + { + "emoji": "🫸", + "aliases": [ + "rightwards_pushing_hand" + ] + }, + { + "emoji": "💍", + "aliases": [ + "ring" + ] + }, + { + "emoji": "🛟", + "aliases": [ + "ring_buoy" + ] + }, + { + "emoji": "🪐", + "aliases": [ + "ringed_planet" + ] + }, + { + "emoji": "🤖", + "aliases": [ + "robot" + ] + }, + { + "emoji": "🪨", + "aliases": [ + "rock" + ] + }, + { + "emoji": "🚀", + "aliases": [ + "rocket" + ] + }, + { + "emoji": "🤣", + "aliases": [ + "rofl" + ] + }, + { + "emoji": "🙄", + "aliases": [ + "roll_eyes" + ] + }, + { + "emoji": "🧻", + "aliases": [ + "roll_of_paper" + ] + }, + { + "emoji": "🎢", + "aliases": [ + "roller_coaster" + ] + }, + { + "emoji": "🛼", + "aliases": [ + "roller_skate" + ] + }, + { + "emoji": "🇷🇴", + "aliases": [ + "romania" + ] + }, + { + "emoji": "🐓", + "aliases": [ + "rooster" + ] + }, + { + "emoji": "🫜", + "aliases": [ + "root_vegetable" + ] + }, + { + "emoji": "🌹", + "aliases": [ + "rose" + ] + }, + { + "emoji": "🏵️", + "aliases": [ + "rosette" + ] + }, + { + "emoji": "🚨", + "aliases": [ + "rotating_light" + ] + }, + { + "emoji": "📍", + "aliases": [ + "round_pushpin" + ] + }, + { + "emoji": "🚣", + "aliases": [ + "rowboat" + ] + }, + { + "emoji": "🚣‍♂️", + "aliases": [ + "rowing_man" + ] + }, + { + "emoji": "🚣‍♀️", + "aliases": [ + "rowing_woman" + ] + }, + { + "emoji": "🇷🇺", + "aliases": [ + "ru" + ] + }, + { + "emoji": "🏉", + "aliases": [ + "rugby_football" + ] + }, + { + "emoji": "🏃", + "aliases": [ + "runner", + "running" + ] + }, + { + "emoji": "🏃‍♂️", + "aliases": [ + "running_man" + ] + }, + { + "emoji": "🎽", + "aliases": [ + "running_shirt_with_sash" + ] + }, + { + "emoji": "🏃‍♀️", + "aliases": [ + "running_woman" + ] + }, + { + "emoji": "🇷🇼", + "aliases": [ + "rwanda" + ] + }, + { + "emoji": "🈂️", + "aliases": [ + "sa" + ] + }, + { + "emoji": "🧷", + "aliases": [ + "safety_pin" + ] + }, + { + "emoji": "🦺", + "aliases": [ + "safety_vest" + ] + }, + { + "emoji": "♐", + "aliases": [ + "sagittarius" + ] + }, + { + "emoji": "🍶", + "aliases": [ + "sake" + ] + }, + { + "emoji": "🧂", + "aliases": [ + "salt" + ] + }, + { + "emoji": "🫡", + "aliases": [ + "saluting_face" + ] + }, + { + "emoji": "🇼🇸", + "aliases": [ + "samoa" + ] + }, + { + "emoji": "🇸🇲", + "aliases": [ + "san_marino" + ] + }, + { + "emoji": "👡", + "aliases": [ + "sandal" + ] + }, + { + "emoji": "🥪", + "aliases": [ + "sandwich" + ] + }, + { + "emoji": "🎅", + "aliases": [ + "santa" + ] + }, + { + "emoji": "🇸🇹", + "aliases": [ + "sao_tome_principe" + ] + }, + { + "emoji": "🥻", + "aliases": [ + "sari" + ] + }, + { + "emoji": "📡", + "aliases": [ + "satellite" + ] + }, + { + "emoji": "🇸🇦", + "aliases": [ + "saudi_arabia" + ] + }, + { + "emoji": "🧖‍♂️", + "aliases": [ + "sauna_man" + ] + }, + { + "emoji": "🧖", + "aliases": [ + "sauna_person" + ] + }, + { + "emoji": "🧖‍♀️", + "aliases": [ + "sauna_woman" + ] + }, + { + "emoji": "🦕", + "aliases": [ + "sauropod" + ] + }, + { + "emoji": "🎷", + "aliases": [ + "saxophone" + ] + }, + { + "emoji": "🧣", + "aliases": [ + "scarf" + ] + }, + { + "emoji": "🏫", + "aliases": [ + "school" + ] + }, + { + "emoji": "🎒", + "aliases": [ + "school_satchel" + ] + }, + { + "emoji": "🧑‍🔬", + "aliases": [ + "scientist" + ] + }, + { + "emoji": "✂️", + "aliases": [ + "scissors" + ] + }, + { + "emoji": "🦂", + "aliases": [ + "scorpion" + ] + }, + { + "emoji": "♏", + "aliases": [ + "scorpius" + ] + }, + { + "emoji": "🏴󠁧󠁢󠁳󠁣󠁴󠁿", + "aliases": [ + "scotland" + ] + }, + { + "emoji": "😱", + "aliases": [ + "scream" + ] + }, + { + "emoji": "🙀", + "aliases": [ + "scream_cat" + ] + }, + { + "emoji": "🪛", + "aliases": [ + "screwdriver" + ] + }, + { + "emoji": "📜", + "aliases": [ + "scroll" + ] + }, + { + "emoji": "🦭", + "aliases": [ + "seal" + ] + }, + { + "emoji": "💺", + "aliases": [ + "seat" + ] + }, + { + "emoji": "㊙️", + "aliases": [ + "secret" + ] + }, + { + "emoji": "🙈", + "aliases": [ + "see_no_evil" + ] + }, + { + "emoji": "🌱", + "aliases": [ + "seedling" + ] + }, + { + "emoji": "🤳", + "aliases": [ + "selfie" + ] + }, + { + "emoji": "🇸🇳", + "aliases": [ + "senegal" + ] + }, + { + "emoji": "🇷🇸", + "aliases": [ + "serbia" + ] + }, + { + "emoji": "🐕‍🦺", + "aliases": [ + "service_dog" + ] + }, + { + "emoji": "7️⃣", + "aliases": [ + "seven" + ] + }, + { + "emoji": "🪡", + "aliases": [ + "sewing_needle" + ] + }, + { + "emoji": "🇸🇨", + "aliases": [ + "seychelles" + ] + }, + { + "emoji": "🫨", + "aliases": [ + "shaking_face" + ] + }, + { + "emoji": "🥘", + "aliases": [ + "shallow_pan_of_food" + ] + }, + { + "emoji": "☘️", + "aliases": [ + "shamrock" + ] + }, + { + "emoji": "🦈", + "aliases": [ + "shark" + ] + }, + { + "emoji": "🍧", + "aliases": [ + "shaved_ice" + ] + }, + { + "emoji": "🐑", + "aliases": [ + "sheep" + ] + }, + { + "emoji": "🐚", + "aliases": [ + "shell" + ] + }, + { + "emoji": "🛡️", + "aliases": [ + "shield" + ] + }, + { + "emoji": "⛩️", + "aliases": [ + "shinto_shrine" + ] + }, + { + "emoji": "🚢", + "aliases": [ + "ship" + ] + }, + { + "emoji": "👕", + "aliases": [ + "shirt", + "tshirt" + ] + }, + { + "emoji": "🛍️", + "aliases": [ + "shopping" + ] + }, + { + "emoji": "🛒", + "aliases": [ + "shopping_cart" + ] + }, + { + "emoji": "🩳", + "aliases": [ + "shorts" + ] + }, + { + "emoji": "🪏", + "aliases": [ + "shovel" + ] + }, + { + "emoji": "🚿", + "aliases": [ + "shower" + ] + }, + { + "emoji": "🦐", + "aliases": [ + "shrimp" + ] + }, + { + "emoji": "🤷", + "aliases": [ + "shrug" + ] + }, + { + "emoji": "🤫", + "aliases": [ + "shushing_face" + ] + }, + { + "emoji": "🇸🇱", + "aliases": [ + "sierra_leone" + ] + }, + { + "emoji": "📶", + "aliases": [ + "signal_strength" + ] + }, + { + "emoji": "🇸🇬", + "aliases": [ + "singapore" + ] + }, + { + "emoji": "🧑‍🎤", + "aliases": [ + "singer" + ] + }, + { + "emoji": "🇸🇽", + "aliases": [ + "sint_maarten" + ] + }, + { + "emoji": "6️⃣", + "aliases": [ + "six" + ] + }, + { + "emoji": "🔯", + "aliases": [ + "six_pointed_star" + ] + }, + { + "emoji": "🛹", + "aliases": [ + "skateboard" + ] + }, + { + "emoji": "🎿", + "aliases": [ + "ski" + ] + }, + { + "emoji": "⛷️", + "aliases": [ + "skier" + ] + }, + { + "emoji": "💀", + "aliases": [ + "skull" + ] + }, + { + "emoji": "☠️", + "aliases": [ + "skull_and_crossbones" + ] + }, + { + "emoji": "🦨", + "aliases": [ + "skunk" + ] + }, + { + "emoji": "🛷", + "aliases": [ + "sled" + ] + }, + { + "emoji": "😴", + "aliases": [ + "sleeping" + ] + }, + { + "emoji": "🛌", + "aliases": [ + "sleeping_bed" + ] + }, + { + "emoji": "😪", + "aliases": [ + "sleepy" + ] + }, + { + "emoji": "🙁", + "aliases": [ + "slightly_frowning_face" + ] + }, + { + "emoji": "🙂", + "aliases": [ + "slightly_smiling_face" + ] + }, + { + "emoji": "🎰", + "aliases": [ + "slot_machine" + ] + }, + { + "emoji": "🦥", + "aliases": [ + "sloth" + ] + }, + { + "emoji": "🇸🇰", + "aliases": [ + "slovakia" + ] + }, + { + "emoji": "🇸🇮", + "aliases": [ + "slovenia" + ] + }, + { + "emoji": "🛩️", + "aliases": [ + "small_airplane" + ] + }, + { + "emoji": "🔹", + "aliases": [ + "small_blue_diamond" + ] + }, + { + "emoji": "🔸", + "aliases": [ + "small_orange_diamond" + ] + }, + { + "emoji": "🔺", + "aliases": [ + "small_red_triangle" + ] + }, + { + "emoji": "🔻", + "aliases": [ + "small_red_triangle_down" + ] + }, + { + "emoji": "😄", + "aliases": [ + "smile" + ] + }, + { + "emoji": "😸", + "aliases": [ + "smile_cat" + ] + }, + { + "emoji": "😃", + "aliases": [ + "smiley" + ] + }, + { + "emoji": "😺", + "aliases": [ + "smiley_cat" + ] + }, + { + "emoji": "🥲", + "aliases": [ + "smiling_face_with_tear" + ] + }, + { + "emoji": "🥰", + "aliases": [ + "smiling_face_with_three_hearts" + ] + }, + { + "emoji": "😈", + "aliases": [ + "smiling_imp" + ] + }, + { + "emoji": "😏", + "aliases": [ + "smirk" + ] + }, + { + "emoji": "😼", + "aliases": [ + "smirk_cat" + ] + }, + { + "emoji": "🚬", + "aliases": [ + "smoking" + ] + }, + { + "emoji": "🐌", + "aliases": [ + "snail" + ] + }, + { + "emoji": "🐍", + "aliases": [ + "snake" + ] + }, + { + "emoji": "🤧", + "aliases": [ + "sneezing_face" + ] + }, + { + "emoji": "🏂", + "aliases": [ + "snowboarder" + ] + }, + { + "emoji": "❄️", + "aliases": [ + "snowflake" + ] + }, + { + "emoji": "⛄", + "aliases": [ + "snowman" + ] + }, + { + "emoji": "☃️", + "aliases": [ + "snowman_with_snow" + ] + }, + { + "emoji": "🧼", + "aliases": [ + "soap" + ] + }, + { + "emoji": "😭", + "aliases": [ + "sob" + ] + }, + { + "emoji": "⚽", + "aliases": [ + "soccer" + ] + }, + { + "emoji": "🧦", + "aliases": [ + "socks" + ] + }, + { + "emoji": "🥎", + "aliases": [ + "softball" + ] + }, + { + "emoji": "🇸🇧", + "aliases": [ + "solomon_islands" + ] + }, + { + "emoji": "🇸🇴", + "aliases": [ + "somalia" + ] + }, + { + "emoji": "🔜", + "aliases": [ + "soon" + ] + }, + { + "emoji": "🆘", + "aliases": [ + "sos" + ] + }, + { + "emoji": "🔉", + "aliases": [ + "sound" + ] + }, + { + "emoji": "🇿🇦", + "aliases": [ + "south_africa" + ] + }, + { + "emoji": "🇬🇸", + "aliases": [ + "south_georgia_south_sandwich_islands" + ] + }, + { + "emoji": "🇸🇸", + "aliases": [ + "south_sudan" + ] + }, + { + "emoji": "👾", + "aliases": [ + "space_invader" + ] + }, + { + "emoji": "♠️", + "aliases": [ + "spades" + ] + }, + { + "emoji": "🍝", + "aliases": [ + "spaghetti" + ] + }, + { + "emoji": "❇️", + "aliases": [ + "sparkle" + ] + }, + { + "emoji": "🎇", + "aliases": [ + "sparkler" + ] + }, + { + "emoji": "✨", + "aliases": [ + "sparkles" + ] + }, + { + "emoji": "💖", + "aliases": [ + "sparkling_heart" + ] + }, + { + "emoji": "🙊", + "aliases": [ + "speak_no_evil" + ] + }, + { + "emoji": "🔈", + "aliases": [ + "speaker" + ] + }, + { + "emoji": "🗣️", + "aliases": [ + "speaking_head" + ] + }, + { + "emoji": "💬", + "aliases": [ + "speech_balloon" + ] + }, + { + "emoji": "🚤", + "aliases": [ + "speedboat" + ] + }, + { + "emoji": "🕷️", + "aliases": [ + "spider" + ] + }, + { + "emoji": "🕸️", + "aliases": [ + "spider_web" + ] + }, + { + "emoji": "🗓️", + "aliases": [ + "spiral_calendar" + ] + }, + { + "emoji": "🗒️", + "aliases": [ + "spiral_notepad" + ] + }, + { + "emoji": "🫟", + "aliases": [ + "splatter" + ] + }, + { + "emoji": "🧽", + "aliases": [ + "sponge" + ] + }, + { + "emoji": "🥄", + "aliases": [ + "spoon" + ] + }, + { + "emoji": "🦑", + "aliases": [ + "squid" + ] + }, + { + "emoji": "🇱🇰", + "aliases": [ + "sri_lanka" + ] + }, + { + "emoji": "🇧🇱", + "aliases": [ + "st_barthelemy" + ] + }, + { + "emoji": "🇸🇭", + "aliases": [ + "st_helena" + ] + }, + { + "emoji": "🇰🇳", + "aliases": [ + "st_kitts_nevis" + ] + }, + { + "emoji": "🇱🇨", + "aliases": [ + "st_lucia" + ] + }, + { + "emoji": "🇲🇫", + "aliases": [ + "st_martin" + ] + }, + { + "emoji": "🇵🇲", + "aliases": [ + "st_pierre_miquelon" + ] + }, + { + "emoji": "🇻🇨", + "aliases": [ + "st_vincent_grenadines" + ] + }, + { + "emoji": "🏟️", + "aliases": [ + "stadium" + ] + }, + { + "emoji": "🧍‍♂️", + "aliases": [ + "standing_man" + ] + }, + { + "emoji": "🧍", + "aliases": [ + "standing_person" + ] + }, + { + "emoji": "🧍‍♀️", + "aliases": [ + "standing_woman" + ] + }, + { + "emoji": "⭐", + "aliases": [ + "star" + ] + }, + { + "emoji": "🌟", + "aliases": [ + "star2" + ] + }, + { + "emoji": "☪️", + "aliases": [ + "star_and_crescent" + ] + }, + { + "emoji": "✡️", + "aliases": [ + "star_of_david" + ] + }, + { + "emoji": "🤩", + "aliases": [ + "star_struck" + ] + }, + { + "emoji": "🌠", + "aliases": [ + "stars" + ] + }, + { + "emoji": "🚉", + "aliases": [ + "station" + ] + }, + { + "emoji": "🗽", + "aliases": [ + "statue_of_liberty" + ] + }, + { + "emoji": "🚂", + "aliases": [ + "steam_locomotive" + ] + }, + { + "emoji": "🩺", + "aliases": [ + "stethoscope" + ] + }, + { + "emoji": "🍲", + "aliases": [ + "stew" + ] + }, + { + "emoji": "⏹️", + "aliases": [ + "stop_button" + ] + }, + { + "emoji": "🛑", + "aliases": [ + "stop_sign" + ] + }, + { + "emoji": "⏱️", + "aliases": [ + "stopwatch" + ] + }, + { + "emoji": "📏", + "aliases": [ + "straight_ruler" + ] + }, + { + "emoji": "🍓", + "aliases": [ + "strawberry" + ] + }, + { + "emoji": "😛", + "aliases": [ + "stuck_out_tongue" + ] + }, + { + "emoji": "😝", + "aliases": [ + "stuck_out_tongue_closed_eyes" + ] + }, + { + "emoji": "😜", + "aliases": [ + "stuck_out_tongue_winking_eye" + ] + }, + { + "emoji": "🧑‍🎓", + "aliases": [ + "student" + ] + }, + { + "emoji": "🎙️", + "aliases": [ + "studio_microphone" + ] + }, + { + "emoji": "🥙", + "aliases": [ + "stuffed_flatbread" + ] + }, + { + "emoji": "🇸🇩", + "aliases": [ + "sudan" + ] + }, + { + "emoji": "🌥️", + "aliases": [ + "sun_behind_large_cloud" + ] + }, + { + "emoji": "🌦️", + "aliases": [ + "sun_behind_rain_cloud" + ] + }, + { + "emoji": "🌤️", + "aliases": [ + "sun_behind_small_cloud" + ] + }, + { + "emoji": "🌞", + "aliases": [ + "sun_with_face" + ] + }, + { + "emoji": "🌻", + "aliases": [ + "sunflower" + ] + }, + { + "emoji": "😎", + "aliases": [ + "sunglasses" + ] + }, + { + "emoji": "☀️", + "aliases": [ + "sunny" + ] + }, + { + "emoji": "🌅", + "aliases": [ + "sunrise" + ] + }, + { + "emoji": "🌄", + "aliases": [ + "sunrise_over_mountains" + ] + }, + { + "emoji": "🦸", + "aliases": [ + "superhero" + ] + }, + { + "emoji": "🦸‍♂️", + "aliases": [ + "superhero_man" + ] + }, + { + "emoji": "🦸‍♀️", + "aliases": [ + "superhero_woman" + ] + }, + { + "emoji": "🦹", + "aliases": [ + "supervillain" + ] + }, + { + "emoji": "🦹‍♂️", + "aliases": [ + "supervillain_man" + ] + }, + { + "emoji": "🦹‍♀️", + "aliases": [ + "supervillain_woman" + ] + }, + { + "emoji": "🏄", + "aliases": [ + "surfer" + ] + }, + { + "emoji": "🏄‍♂️", + "aliases": [ + "surfing_man" + ] + }, + { + "emoji": "🏄‍♀️", + "aliases": [ + "surfing_woman" + ] + }, + { + "emoji": "🇸🇷", + "aliases": [ + "suriname" + ] + }, + { + "emoji": "🍣", + "aliases": [ + "sushi" + ] + }, + { + "emoji": "🚟", + "aliases": [ + "suspension_railway" + ] + }, + { + "emoji": "🇸🇯", + "aliases": [ + "svalbard_jan_mayen" + ] + }, + { + "emoji": "🦢", + "aliases": [ + "swan" + ] + }, + { + "emoji": "🇸🇿", + "aliases": [ + "swaziland" + ] + }, + { + "emoji": "😓", + "aliases": [ + "sweat" + ] + }, + { + "emoji": "💦", + "aliases": [ + "sweat_drops" + ] + }, + { + "emoji": "😅", + "aliases": [ + "sweat_smile" + ] + }, + { + "emoji": "🇸🇪", + "aliases": [ + "sweden" + ] + }, + { + "emoji": "🍠", + "aliases": [ + "sweet_potato" + ] + }, + { + "emoji": "🩲", + "aliases": [ + "swim_brief" + ] + }, + { + "emoji": "🏊", + "aliases": [ + "swimmer" + ] + }, + { + "emoji": "🏊‍♂️", + "aliases": [ + "swimming_man" + ] + }, + { + "emoji": "🏊‍♀️", + "aliases": [ + "swimming_woman" + ] + }, + { + "emoji": "🇨🇭", + "aliases": [ + "switzerland" + ] + }, + { + "emoji": "🔣", + "aliases": [ + "symbols" + ] + }, + { + "emoji": "🕍", + "aliases": [ + "synagogue" + ] + }, + { + "emoji": "🇸🇾", + "aliases": [ + "syria" + ] + }, + { + "emoji": "💉", + "aliases": [ + "syringe" + ] + }, + { + "emoji": "🦖", + "aliases": [ + "t-rex" + ] + }, + { + "emoji": "🌮", + "aliases": [ + "taco" + ] + }, + { + "emoji": "🎉", + "aliases": [ + "tada", + "hooray" + ] + }, + { + "emoji": "🇹🇼", + "aliases": [ + "taiwan" + ] + }, + { + "emoji": "🇹🇯", + "aliases": [ + "tajikistan" + ] + }, + { + "emoji": "🥡", + "aliases": [ + "takeout_box" + ] + }, + { + "emoji": "🫔", + "aliases": [ + "tamale" + ] + }, + { + "emoji": "🎋", + "aliases": [ + "tanabata_tree" + ] + }, + { + "emoji": "🍊", + "aliases": [ + "tangerine", + "orange", + "mandarin" + ] + }, + { + "emoji": "🇹🇿", + "aliases": [ + "tanzania" + ] + }, + { + "emoji": "♉", + "aliases": [ + "taurus" + ] + }, + { + "emoji": "🚕", + "aliases": [ + "taxi" + ] + }, + { + "emoji": "🍵", + "aliases": [ + "tea" + ] + }, + { + "emoji": "🧑‍🏫", + "aliases": [ + "teacher" + ] + }, + { + "emoji": "🫖", + "aliases": [ + "teapot" + ] + }, + { + "emoji": "🧑‍💻", + "aliases": [ + "technologist" + ] + }, + { + "emoji": "🧸", + "aliases": [ + "teddy_bear" + ] + }, + { + "emoji": "📞", + "aliases": [ + "telephone_receiver" + ] + }, + { + "emoji": "🔭", + "aliases": [ + "telescope" + ] + }, + { + "emoji": "🎾", + "aliases": [ + "tennis" + ] + }, + { + "emoji": "⛺", + "aliases": [ + "tent" + ] + }, + { + "emoji": "🧪", + "aliases": [ + "test_tube" + ] + }, + { + "emoji": "🇹🇭", + "aliases": [ + "thailand" + ] + }, + { + "emoji": "🌡️", + "aliases": [ + "thermometer" + ] + }, + { + "emoji": "🤔", + "aliases": [ + "thinking" + ] + }, + { + "emoji": "🩴", + "aliases": [ + "thong_sandal" + ] + }, + { + "emoji": "💭", + "aliases": [ + "thought_balloon" + ] + }, + { + "emoji": "🧵", + "aliases": [ + "thread" + ] + }, + { + "emoji": "3️⃣", + "aliases": [ + "three" + ] + }, + { + "emoji": "🎫", + "aliases": [ + "ticket" + ] + }, + { + "emoji": "🎟️", + "aliases": [ + "tickets" + ] + }, + { + "emoji": "🐯", + "aliases": [ + "tiger" + ] + }, + { + "emoji": "🐅", + "aliases": [ + "tiger2" + ] + }, + { + "emoji": "⏲️", + "aliases": [ + "timer_clock" + ] + }, + { + "emoji": "🇹🇱", + "aliases": [ + "timor_leste" + ] + }, + { + "emoji": "💁‍♂️", + "aliases": [ + "tipping_hand_man", + "sassy_man" + ] + }, + { + "emoji": "💁", + "aliases": [ + "tipping_hand_person", + "information_desk_person" + ] + }, + { + "emoji": "💁‍♀️", + "aliases": [ + "tipping_hand_woman", + "sassy_woman" + ] + }, + { + "emoji": "😫", + "aliases": [ + "tired_face" + ] + }, + { + "emoji": "™️", + "aliases": [ + "tm" + ] + }, + { + "emoji": "🇹🇬", + "aliases": [ + "togo" + ] + }, + { + "emoji": "🚽", + "aliases": [ + "toilet" + ] + }, + { + "emoji": "🇹🇰", + "aliases": [ + "tokelau" + ] + }, + { + "emoji": "🗼", + "aliases": [ + "tokyo_tower" + ] + }, + { + "emoji": "🍅", + "aliases": [ + "tomato" + ] + }, + { + "emoji": "🇹🇴", + "aliases": [ + "tonga" + ] + }, + { + "emoji": "👅", + "aliases": [ + "tongue" + ] + }, + { + "emoji": "🧰", + "aliases": [ + "toolbox" + ] + }, + { + "emoji": "🦷", + "aliases": [ + "tooth" + ] + }, + { + "emoji": "🪥", + "aliases": [ + "toothbrush" + ] + }, + { + "emoji": "🔝", + "aliases": [ + "top" + ] + }, + { + "emoji": "🎩", + "aliases": [ + "tophat" + ] + }, + { + "emoji": "🌪️", + "aliases": [ + "tornado" + ] + }, + { + "emoji": "🇹🇷", + "aliases": [ + "tr" + ] + }, + { + "emoji": "🖲️", + "aliases": [ + "trackball" + ] + }, + { + "emoji": "🚜", + "aliases": [ + "tractor" + ] + }, + { + "emoji": "🚥", + "aliases": [ + "traffic_light" + ] + }, + { + "emoji": "🚋", + "aliases": [ + "train" + ] + }, + { + "emoji": "🚆", + "aliases": [ + "train2" + ] + }, + { + "emoji": "🚊", + "aliases": [ + "tram" + ] + }, + { + "emoji": "🏳️‍⚧️", + "aliases": [ + "transgender_flag" + ] + }, + { + "emoji": "⚧️", + "aliases": [ + "transgender_symbol" + ] + }, + { + "emoji": "🚩", + "aliases": [ + "triangular_flag_on_post" + ] + }, + { + "emoji": "📐", + "aliases": [ + "triangular_ruler" + ] + }, + { + "emoji": "🔱", + "aliases": [ + "trident" + ] + }, + { + "emoji": "🇹🇹", + "aliases": [ + "trinidad_tobago" + ] + }, + { + "emoji": "🇹🇦", + "aliases": [ + "tristan_da_cunha" + ] + }, + { + "emoji": "😤", + "aliases": [ + "triumph" + ] + }, + { + "emoji": "🧌", + "aliases": [ + "troll" + ] + }, + { + "emoji": "🚎", + "aliases": [ + "trolleybus" + ] + }, + { + "emoji": "🏆", + "aliases": [ + "trophy" + ] + }, + { + "emoji": "🍹", + "aliases": [ + "tropical_drink" + ] + }, + { + "emoji": "🐠", + "aliases": [ + "tropical_fish" + ] + }, + { + "emoji": "🚚", + "aliases": [ + "truck" + ] + }, + { + "emoji": "🎺", + "aliases": [ + "trumpet" + ] + }, + { + "emoji": "🌷", + "aliases": [ + "tulip" + ] + }, + { + "emoji": "🥃", + "aliases": [ + "tumbler_glass" + ] + }, + { + "emoji": "🇹🇳", + "aliases": [ + "tunisia" + ] + }, + { + "emoji": "🦃", + "aliases": [ + "turkey" + ] + }, + { + "emoji": "🇹🇲", + "aliases": [ + "turkmenistan" + ] + }, + { + "emoji": "🇹🇨", + "aliases": [ + "turks_caicos_islands" + ] + }, + { + "emoji": "🐢", + "aliases": [ + "turtle" + ] + }, + { + "emoji": "🇹🇻", + "aliases": [ + "tuvalu" + ] + }, + { + "emoji": "📺", + "aliases": [ + "tv" + ] + }, + { + "emoji": "🔀", + "aliases": [ + "twisted_rightwards_arrows" + ] + }, + { + "emoji": "2️⃣", + "aliases": [ + "two" + ] + }, + { + "emoji": "💕", + "aliases": [ + "two_hearts" + ] + }, + { + "emoji": "👬", + "aliases": [ + "two_men_holding_hands" + ] + }, + { + "emoji": "👭", + "aliases": [ + "two_women_holding_hands" + ] + }, + { + "emoji": "🈹", + "aliases": [ + "u5272" + ] + }, + { + "emoji": "🈴", + "aliases": [ + "u5408" + ] + }, + { + "emoji": "🈺", + "aliases": [ + "u55b6" + ] + }, + { + "emoji": "🈯", + "aliases": [ + "u6307" + ] + }, + { + "emoji": "🈷️", + "aliases": [ + "u6708" + ] + }, + { + "emoji": "🈶", + "aliases": [ + "u6709" + ] + }, + { + "emoji": "🈵", + "aliases": [ + "u6e80" + ] + }, + { + "emoji": "🈚", + "aliases": [ + "u7121" + ] + }, + { + "emoji": "🈸", + "aliases": [ + "u7533" + ] + }, + { + "emoji": "🈲", + "aliases": [ + "u7981" + ] + }, + { + "emoji": "🈳", + "aliases": [ + "u7a7a" + ] + }, + { + "emoji": "🇺🇬", + "aliases": [ + "uganda" + ] + }, + { + "emoji": "🇺🇦", + "aliases": [ + "ukraine" + ] + }, + { + "emoji": "☔", + "aliases": [ + "umbrella" + ] + }, + { + "emoji": "😒", + "aliases": [ + "unamused" + ] + }, + { + "emoji": "🔞", + "aliases": [ + "underage" + ] + }, + { + "emoji": "🦄", + "aliases": [ + "unicorn" + ] + }, + { + "emoji": "🇦🇪", + "aliases": [ + "united_arab_emirates" + ] + }, + { + "emoji": "🇺🇳", + "aliases": [ + "united_nations" + ] + }, + { + "emoji": "🔓", + "aliases": [ + "unlock" + ] + }, + { + "emoji": "🆙", + "aliases": [ + "up" + ] + }, + { + "emoji": "🙃", + "aliases": [ + "upside_down_face" + ] + }, + { + "emoji": "🇺🇾", + "aliases": [ + "uruguay" + ] + }, + { + "emoji": "🇺🇸", + "aliases": [ + "us" + ] + }, + { + "emoji": "🇺🇲", + "aliases": [ + "us_outlying_islands" + ] + }, + { + "emoji": "🇻🇮", + "aliases": [ + "us_virgin_islands" + ] + }, + { + "emoji": "🇺🇿", + "aliases": [ + "uzbekistan" + ] + }, + { + "emoji": "✌️", + "aliases": [ + "v" + ] + }, + { + "emoji": "🧛", + "aliases": [ + "vampire" + ] + }, + { + "emoji": "🧛‍♂️", + "aliases": [ + "vampire_man" + ] + }, + { + "emoji": "🧛‍♀️", + "aliases": [ + "vampire_woman" + ] + }, + { + "emoji": "🇻🇺", + "aliases": [ + "vanuatu" + ] + }, + { + "emoji": "🇻🇦", + "aliases": [ + "vatican_city" + ] + }, + { + "emoji": "🇻🇪", + "aliases": [ + "venezuela" + ] + }, + { + "emoji": "🚦", + "aliases": [ + "vertical_traffic_light" + ] + }, + { + "emoji": "📼", + "aliases": [ + "vhs" + ] + }, + { + "emoji": "📳", + "aliases": [ + "vibration_mode" + ] + }, + { + "emoji": "📹", + "aliases": [ + "video_camera" + ] + }, + { + "emoji": "🎮", + "aliases": [ + "video_game" + ] + }, + { + "emoji": "🇻🇳", + "aliases": [ + "vietnam" + ] + }, + { + "emoji": "🎻", + "aliases": [ + "violin" + ] + }, + { + "emoji": "♍", + "aliases": [ + "virgo" + ] + }, + { + "emoji": "🌋", + "aliases": [ + "volcano" + ] + }, + { + "emoji": "🏐", + "aliases": [ + "volleyball" + ] + }, + { + "emoji": "🤮", + "aliases": [ + "vomiting_face" + ] + }, + { + "emoji": "🆚", + "aliases": [ + "vs" + ] + }, + { + "emoji": "🖖", + "aliases": [ + "vulcan_salute" + ] + }, + { + "emoji": "🧇", + "aliases": [ + "waffle" + ] + }, + { + "emoji": "🏴󠁧󠁢󠁷󠁬󠁳󠁿", + "aliases": [ + "wales" + ] + }, + { + "emoji": "🚶", + "aliases": [ + "walking" + ] + }, + { + "emoji": "🚶‍♂️", + "aliases": [ + "walking_man" + ] + }, + { + "emoji": "🚶‍♀️", + "aliases": [ + "walking_woman" + ] + }, + { + "emoji": "🇼🇫", + "aliases": [ + "wallis_futuna" + ] + }, + { + "emoji": "🌘", + "aliases": [ + "waning_crescent_moon" + ] + }, + { + "emoji": "🌖", + "aliases": [ + "waning_gibbous_moon" + ] + }, + { + "emoji": "⚠️", + "aliases": [ + "warning" + ] + }, + { + "emoji": "🗑️", + "aliases": [ + "wastebasket" + ] + }, + { + "emoji": "⌚", + "aliases": [ + "watch" + ] + }, + { + "emoji": "🐃", + "aliases": [ + "water_buffalo" + ] + }, + { + "emoji": "🤽", + "aliases": [ + "water_polo" + ] + }, + { + "emoji": "🍉", + "aliases": [ + "watermelon" + ] + }, + { + "emoji": "👋", + "aliases": [ + "wave" + ] + }, + { + "emoji": "〰️", + "aliases": [ + "wavy_dash" + ] + }, + { + "emoji": "🌒", + "aliases": [ + "waxing_crescent_moon" + ] + }, + { + "emoji": "🚾", + "aliases": [ + "wc" + ] + }, + { + "emoji": "😩", + "aliases": [ + "weary" + ] + }, + { + "emoji": "💒", + "aliases": [ + "wedding" + ] + }, + { + "emoji": "🏋️", + "aliases": [ + "weight_lifting" + ] + }, + { + "emoji": "🏋️‍♂️", + "aliases": [ + "weight_lifting_man" + ] + }, + { + "emoji": "🏋️‍♀️", + "aliases": [ + "weight_lifting_woman" + ] + }, + { + "emoji": "🇪🇭", + "aliases": [ + "western_sahara" + ] + }, + { + "emoji": "🐳", + "aliases": [ + "whale" + ] + }, + { + "emoji": "🐋", + "aliases": [ + "whale2" + ] + }, + { + "emoji": "🛞", + "aliases": [ + "wheel" + ] + }, + { + "emoji": "☸️", + "aliases": [ + "wheel_of_dharma" + ] + }, + { + "emoji": "♿", + "aliases": [ + "wheelchair" + ] + }, + { + "emoji": "✅", + "aliases": [ + "white_check_mark" + ] + }, + { + "emoji": "⚪", + "aliases": [ + "white_circle" + ] + }, + { + "emoji": "🏳️", + "aliases": [ + "white_flag" + ] + }, + { + "emoji": "💮", + "aliases": [ + "white_flower" + ] + }, + { + "emoji": "👨‍🦳", + "aliases": [ + "white_haired_man" + ] + }, + { + "emoji": "👩‍🦳", + "aliases": [ + "white_haired_woman" + ] + }, + { + "emoji": "🤍", + "aliases": [ + "white_heart" + ] + }, + { + "emoji": "⬜", + "aliases": [ + "white_large_square" + ] + }, + { + "emoji": "◽", + "aliases": [ + "white_medium_small_square" + ] + }, + { + "emoji": "◻️", + "aliases": [ + "white_medium_square" + ] + }, + { + "emoji": "▫️", + "aliases": [ + "white_small_square" + ] + }, + { + "emoji": "🔳", + "aliases": [ + "white_square_button" + ] + }, + { + "emoji": "🥀", + "aliases": [ + "wilted_flower" + ] + }, + { + "emoji": "🎐", + "aliases": [ + "wind_chime" + ] + }, + { + "emoji": "🌬️", + "aliases": [ + "wind_face" + ] + }, + { + "emoji": "🪟", + "aliases": [ + "window" + ] + }, + { + "emoji": "🍷", + "aliases": [ + "wine_glass" + ] + }, + { + "emoji": "🪽", + "aliases": [ + "wing" + ] + }, + { + "emoji": "😉", + "aliases": [ + "wink" + ] + }, + { + "emoji": "🛜", + "aliases": [ + "wireless" + ] + }, + { + "emoji": "🐺", + "aliases": [ + "wolf" + ] + }, + { + "emoji": "👩", + "aliases": [ + "woman" + ] + }, + { + "emoji": "👩‍🎨", + "aliases": [ + "woman_artist" + ] + }, + { + "emoji": "👩‍🚀", + "aliases": [ + "woman_astronaut" + ] + }, + { + "emoji": "🧔‍♀️", + "aliases": [ + "woman_beard" + ] + }, + { + "emoji": "🤸‍♀️", + "aliases": [ + "woman_cartwheeling" + ] + }, + { + "emoji": "👩‍🍳", + "aliases": [ + "woman_cook" + ] + }, + { + "emoji": "💃", + "aliases": [ + "woman_dancing", + "dancer" + ] + }, + { + "emoji": "🤦‍♀️", + "aliases": [ + "woman_facepalming" + ] + }, + { + "emoji": "👩‍🏭", + "aliases": [ + "woman_factory_worker" + ] + }, + { + "emoji": "👩‍🌾", + "aliases": [ + "woman_farmer" + ] + }, + { + "emoji": "👩‍🍼", + "aliases": [ + "woman_feeding_baby" + ] + }, + { + "emoji": "👩‍🚒", + "aliases": [ + "woman_firefighter" + ] + }, + { + "emoji": "👩‍⚕️", + "aliases": [ + "woman_health_worker" + ] + }, + { + "emoji": "👩‍🦽", + "aliases": [ + "woman_in_manual_wheelchair" + ] + }, + { + "emoji": "👩‍🦽‍➡️", + "aliases": [ + "woman_in_manual_wheelchair_facing_right" + ] + }, + { + "emoji": "👩‍🦼", + "aliases": [ + "woman_in_motorized_wheelchair" + ] + }, + { + "emoji": "👩‍🦼‍➡️", + "aliases": [ + "woman_in_motorized_wheelchair_facing_right" + ] + }, + { + "emoji": "🤵‍♀️", + "aliases": [ + "woman_in_tuxedo" + ] + }, + { + "emoji": "👩‍⚖️", + "aliases": [ + "woman_judge" + ] + }, + { + "emoji": "🤹‍♀️", + "aliases": [ + "woman_juggling" + ] + }, + { + "emoji": "🧎‍♀️‍➡️", + "aliases": [ + "woman_kneeling_facing_right" + ] + }, + { + "emoji": "👩‍🔧", + "aliases": [ + "woman_mechanic" + ] + }, + { + "emoji": "👩‍💼", + "aliases": [ + "woman_office_worker" + ] + }, + { + "emoji": "👩‍✈️", + "aliases": [ + "woman_pilot" + ] + }, + { + "emoji": "🤾‍♀️", + "aliases": [ + "woman_playing_handball" + ] + }, + { + "emoji": "🤽‍♀️", + "aliases": [ + "woman_playing_water_polo" + ] + }, + { + "emoji": "🏃‍♀️‍➡️", + "aliases": [ + "woman_running_facing_right" + ] + }, + { + "emoji": "👩‍🔬", + "aliases": [ + "woman_scientist" + ] + }, + { + "emoji": "🤷‍♀️", + "aliases": [ + "woman_shrugging" + ] + }, + { + "emoji": "👩‍🎤", + "aliases": [ + "woman_singer" + ] + }, + { + "emoji": "👩‍🎓", + "aliases": [ + "woman_student" + ] + }, + { + "emoji": "👩‍🏫", + "aliases": [ + "woman_teacher" + ] + }, + { + "emoji": "👩‍💻", + "aliases": [ + "woman_technologist" + ] + }, + { + "emoji": "🚶‍♀️‍➡️", + "aliases": [ + "woman_walking_facing_right" + ] + }, + { + "emoji": "🧕", + "aliases": [ + "woman_with_headscarf" + ] + }, + { + "emoji": "👩‍🦯", + "aliases": [ + "woman_with_probing_cane" + ] + }, + { + "emoji": "👳‍♀️", + "aliases": [ + "woman_with_turban" + ] + }, + { + "emoji": "👰‍♀️", + "aliases": [ + "woman_with_veil", + "bride_with_veil" + ] + }, + { + "emoji": "👩‍🦯‍➡️", + "aliases": [ + "woman_with_white_cane_facing_right" + ] + }, + { + "emoji": "👚", + "aliases": [ + "womans_clothes" + ] + }, + { + "emoji": "👒", + "aliases": [ + "womans_hat" + ] + }, + { + "emoji": "🤼‍♀️", + "aliases": [ + "women_wrestling" + ] + }, + { + "emoji": "🚺", + "aliases": [ + "womens" + ] + }, + { + "emoji": "🪵", + "aliases": [ + "wood" + ] + }, + { + "emoji": "🥴", + "aliases": [ + "woozy_face" + ] + }, + { + "emoji": "🗺️", + "aliases": [ + "world_map" + ] + }, + { + "emoji": "🪱", + "aliases": [ + "worm" + ] + }, + { + "emoji": "😟", + "aliases": [ + "worried" + ] + }, + { + "emoji": "🔧", + "aliases": [ + "wrench" + ] + }, + { + "emoji": "🤼", + "aliases": [ + "wrestling" + ] + }, + { + "emoji": "✍️", + "aliases": [ + "writing_hand" + ] + }, + { + "emoji": "❌", + "aliases": [ + "x" + ] + }, + { + "emoji": "🩻", + "aliases": [ + "x_ray" + ] + }, + { + "emoji": "🧶", + "aliases": [ + "yarn" + ] + }, + { + "emoji": "🥱", + "aliases": [ + "yawning_face" + ] + }, + { + "emoji": "🟡", + "aliases": [ + "yellow_circle" + ] + }, + { + "emoji": "💛", + "aliases": [ + "yellow_heart" + ] + }, + { + "emoji": "🟨", + "aliases": [ + "yellow_square" + ] + }, + { + "emoji": "🇾🇪", + "aliases": [ + "yemen" + ] + }, + { + "emoji": "💴", + "aliases": [ + "yen" + ] + }, + { + "emoji": "☯️", + "aliases": [ + "yin_yang" + ] + }, + { + "emoji": "🪀", + "aliases": [ + "yo_yo" + ] + }, + { + "emoji": "😋", + "aliases": [ + "yum" + ] + }, + { + "emoji": "🇿🇲", + "aliases": [ + "zambia" + ] + }, + { + "emoji": "🤪", + "aliases": [ + "zany_face" + ] + }, + { + "emoji": "⚡", + "aliases": [ + "zap" + ] + }, + { + "emoji": "🦓", + "aliases": [ + "zebra" + ] + }, + { + "emoji": "0️⃣", + "aliases": [ + "zero" + ] + }, + { + "emoji": "🇿🇼", + "aliases": [ + "zimbabwe" + ] + }, + { + "emoji": "🤐", + "aliases": [ + "zipper_mouth_face" + ] + }, + { + "emoji": "🧟", + "aliases": [ + "zombie" + ] + }, + { + "emoji": "🧟‍♂️", + "aliases": [ + "zombie_man" + ] + }, + { + "emoji": "🧟‍♀️", + "aliases": [ + "zombie_woman" + ] + }, + { + "emoji": "💤", + "aliases": [ + "zzz" + ] + } +] diff --git a/assets/go-licenses.json b/assets/go-licenses.json index 9c19080e246..30f56e5f872 100644 --- a/assets/go-licenses.json +++ b/assets/go-licenses.json @@ -27,7 +27,7 @@ { "name": "connectrpc.com/connect", "path": "connectrpc.com/connect/LICENSE", - "licenseText": " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright 2021-2024 The Connect Authors\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n" + "licenseText": " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright 2021-2025 The Connect Authors\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n" }, { "name": "dario.cat/mergo", @@ -39,11 +39,6 @@ "path": "filippo.io/edwards25519/LICENSE", "licenseText": "Copyright (c) 2009 The Go Authors. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" }, - { - "name": "git.sr.ht/~mariusor/go-xsd-duration", - "path": "git.sr.ht/~mariusor/go-xsd-duration/LICENSE", - "licenseText": "MIT License\n\nCopyright (c) 2019 Go xsd:duration\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" - }, { "name": "gitea.com/go-chi/binding", "path": "gitea.com/go-chi/binding/LICENSE", @@ -114,6 +109,11 @@ "path": "github.com/ProtonMail/go-crypto/LICENSE", "licenseText": "Copyright (c) 2009 The Go Authors. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" }, + { + "name": "github.com/PuerkitoBio/goquery", + "path": "github.com/PuerkitoBio/goquery/LICENSE", + "licenseText": "Copyright (c) 2012-2021, Martin Angers \u0026 Contributors\nAll rights reserved.\n\nRedistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:\n\n* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.\n\n* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.\n\n* Neither the name of the author nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" + }, { "name": "github.com/RoaringBitmap/roaring/v2", "path": "github.com/RoaringBitmap/roaring/v2/LICENSE", @@ -139,6 +139,11 @@ "path": "github.com/andybalholm/brotli/LICENSE", "licenseText": "Copyright (c) 2009, 2010, 2013-2016 by the Brotli Authors.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n" }, + { + "name": "github.com/andybalholm/cascadia", + "path": "github.com/andybalholm/cascadia/LICENSE", + "licenseText": "Copyright (c) 2011 Andy Balholm. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" + }, { "name": "github.com/anmitsu/go-shlex", "path": "github.com/anmitsu/go-shlex/LICENSE", @@ -190,8 +195,8 @@ "licenseText": "The MIT License (MIT)\n\nCopyright (c) 2015 Aymerick JEHANNE\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n" }, { - "name": "github.com/beorn7/perks/quantile", - "path": "github.com/beorn7/perks/quantile/LICENSE", + "name": "github.com/beorn7/perks", + "path": "github.com/beorn7/perks/LICENSE", "licenseText": "Copyright (C) 2013 Blake Mizerany\n\nPermission is hereby granted, free of charge, to any person obtaining\na copy of this software and associated documentation files (the\n\"Software\"), to deal in the Software without restriction, including\nwithout limitation the rights to use, copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the Software, and to\npermit persons to whom the Software is furnished to do so, subject to\nthe following conditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE\nLIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION\nWITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n" }, { @@ -325,8 +330,8 @@ "licenseText": "The MIT License (MIT)\n\nCopyright (c) 2014 Florian Sundermann\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" }, { - "name": "github.com/bradfitz/gomemcache/memcache", - "path": "github.com/bradfitz/gomemcache/memcache/LICENSE", + "name": "github.com/bradfitz/gomemcache", + "path": "github.com/bradfitz/gomemcache/LICENSE", "licenseText": "\n Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright [yyyy] [name of copyright owner]\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n" }, { @@ -355,8 +360,8 @@ "licenseText": "Copyright (c) 2016 Caleb Spare\n\nMIT License\n\nPermission is hereby granted, free of charge, to any person obtaining\na copy of this software and associated documentation files (the\n\"Software\"), to deal in the Software without restriction, including\nwithout limitation the rights to use, copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the Software, and to\npermit persons to whom the Software is furnished to do so, subject to\nthe following conditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE\nLIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION\nWITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n" }, { - "name": "github.com/charmbracelet/git-lfs-transfer/transfer", - "path": "github.com/charmbracelet/git-lfs-transfer/transfer/LICENSE", + "name": "github.com/charmbracelet/git-lfs-transfer", + "path": "github.com/charmbracelet/git-lfs-transfer/LICENSE", "licenseText": "MIT License\n\nCopyright (c) 2022-2023 Charmbracelet, Inc\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" }, { @@ -385,8 +390,8 @@ "licenseText": "Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"{}\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright {yyyy} {name of copyright owner}\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n\n" }, { - "name": "github.com/cpuguy83/go-md2man/v2/md2man", - "path": "github.com/cpuguy83/go-md2man/v2/md2man/LICENSE.md", + "name": "github.com/cpuguy83/go-md2man/v2", + "path": "github.com/cpuguy83/go-md2man/v2/LICENSE.md", "licenseText": "The MIT License (MIT)\n\nCopyright (c) 2014 Brian Goff\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" }, { @@ -395,8 +400,8 @@ "licenseText": "Copyright (C) 2014-2015 Docker Inc \u0026 Go Authors. All rights reserved.\nCopyright (C) 2017-2024 SUSE LLC. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" }, { - "name": "github.com/davecgh/go-spew/spew", - "path": "github.com/davecgh/go-spew/spew/LICENSE", + "name": "github.com/davecgh/go-spew", + "path": "github.com/davecgh/go-spew/LICENSE", "licenseText": "ISC License\n\nCopyright (c) 2012-2016 Dave Collins \u003cdave@davec.name\u003e\n\nPermission to use, copy, modify, and/or distribute this software for any\npurpose with or without fee is hereby granted, provided that the above\ncopyright notice and this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES\nWITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF\nMERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR\nANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES\nWHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN\nACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF\nOR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.\n" }, { @@ -409,16 +414,6 @@ "path": "github.com/dimiro1/reply/LICENSE", "licenseText": "MIT License\n\nCopyright (c) Discourse\nCopyright (c) Claudemiro\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" }, - { - "name": "github.com/djherbis/buffer", - "path": "github.com/djherbis/buffer/LICENSE.txt", - "licenseText": "The MIT License (MIT)\n\nCopyright (c) 2015 Dustin H\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of\nthis software and associated documentation files (the \"Software\"), to deal in\nthe Software without restriction, including without limitation the rights to\nuse, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software is furnished to do so,\nsubject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS\nFOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR\nCOPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER\nIN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN\nCONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n" - }, - { - "name": "github.com/djherbis/nio/v3", - "path": "github.com/djherbis/nio/v3/LICENSE.txt", - "licenseText": "The MIT License (MIT)\n\nCopyright (c) 2015 Dustin H\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of\nthis software and associated documentation files (the \"Software\"), to deal in\nthe Software without restriction, including without limitation the rights to\nuse, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software is furnished to do so,\nsubject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS\nFOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR\nCOPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER\nIN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN\nCONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n" - }, { "name": "github.com/dlclark/regexp2", "path": "github.com/dlclark/regexp2/LICENSE", @@ -489,21 +484,6 @@ "path": "github.com/gliderlabs/ssh/LICENSE", "licenseText": "Copyright (c) 2016 Glider Labs. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Glider Labs nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" }, - { - "name": "github.com/go-ap/activitypub", - "path": "github.com/go-ap/activitypub/LICENSE", - "licenseText": "MIT License\n\nCopyright (c) 2017 Golang ActitvityPub\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" - }, - { - "name": "github.com/go-ap/errors", - "path": "github.com/go-ap/errors/LICENSE", - "licenseText": "MIT License\n\nCopyright (c) 2019 Golang ActitvityPub\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" - }, - { - "name": "github.com/go-ap/jsonld", - "path": "github.com/go-ap/jsonld/LICENSE", - "licenseText": "MIT License\n\nCopyright (c) 2017 Marius Orcsik\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" - }, { "name": "github.com/go-asn1-ber/asn1-ber", "path": "github.com/go-asn1-ber/asn1-ber/LICENSE", @@ -574,6 +554,11 @@ "path": "github.com/go-webauthn/webauthn/LICENSE", "licenseText": "Copyright (c) 2017 Duo Security, Inc. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions\nare met:\n\n1. Redistributions of source code must retain the above copyright\n notice, this list of conditions and the following disclaimer.\n2. Redistributions in binary form must reproduce the above copyright\n notice, this list of conditions and the following disclaimer in the\n documentation and/or other materials provided with the distribution.\n3. Neither the name of the copyright holder nor the names of its\n contributors may be used to endorse or promote products derived from\n this software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS\nIS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR\nCONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,\nEXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,\nPROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR\nPROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF\nLIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING\nNEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS\nSOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n\n\nCopyright (c) 2021-2022 github.com/go-webauthn/webauthn authors.\n\nRedistribution and use in source and binary forms, with or without modification, are permitted provided that the\nfollowing conditions are met:\n\n1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following\n disclaimer.\n\n2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following\n disclaimer in the documentation and/or other materials provided with the distribution.\n\n3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products\n derived from this software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES,\nINCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\nDISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR\nSERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,\nWHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF\nTHIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE." }, + { + "name": "github.com/go-webauthn/x", + "path": "github.com/go-webauthn/x/LICENSE", + "licenseText": "Copyright (c) 2021-2023 github.com/go-webauthn authors.\n\nRedistribution and use in source and binary forms, with or without modification, are permitted provided that the\nfollowing conditions are met:\n\n1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following\n disclaimer.\n\n2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following\n disclaimer in the documentation and/or other materials provided with the distribution.\n\n3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products\n derived from this software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES,\nINCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\nDISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR\nSERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,\nWHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF\nTHIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE." + }, { "name": "github.com/go-webauthn/x/revoke", "path": "github.com/go-webauthn/x/revoke/LICENSE", @@ -594,11 +579,6 @@ "path": "github.com/gogs/go-gogs-client/LICENSE", "licenseText": "The MIT License (MIT)\n\nCopyright (c) 2014 Go Git Service\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n" }, - { - "name": "github.com/golang-jwt/jwt/v4", - "path": "github.com/golang-jwt/jwt/v4/LICENSE", - "licenseText": "Copyright (c) 2012 Dave Grijalva\nCopyright (c) 2021 golang-jwt maintainers\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n\n" - }, { "name": "github.com/golang-jwt/jwt/v5", "path": "github.com/golang-jwt/jwt/v5/LICENSE", @@ -615,15 +595,10 @@ "licenseText": "Copyright (c) 2017 The Go Authors. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" }, { - "name": "github.com/golang/groupcache/lru", - "path": "github.com/golang/groupcache/lru/LICENSE", + "name": "github.com/golang/groupcache", + "path": "github.com/golang/groupcache/LICENSE", "licenseText": "Apache License\nVersion 2.0, January 2004\nhttp://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n\"License\" shall mean the terms and conditions for use, reproduction, and\ndistribution as defined by Sections 1 through 9 of this document.\n\n\"Licensor\" shall mean the copyright owner or entity authorized by the copyright\nowner that is granting the License.\n\n\"Legal Entity\" shall mean the union of the acting entity and all other entities\nthat control, are controlled by, or are under common control with that entity.\nFor the purposes of this definition, \"control\" means (i) the power, direct or\nindirect, to cause the direction or management of such entity, whether by\ncontract or otherwise, or (ii) ownership of fifty percent (50%) or more of the\noutstanding shares, or (iii) beneficial ownership of such entity.\n\n\"You\" (or \"Your\") shall mean an individual or Legal Entity exercising\npermissions granted by this License.\n\n\"Source\" form shall mean the preferred form for making modifications, including\nbut not limited to software source code, documentation source, and configuration\nfiles.\n\n\"Object\" form shall mean any form resulting from mechanical transformation or\ntranslation of a Source form, including but not limited to compiled object code,\ngenerated documentation, and conversions to other media types.\n\n\"Work\" shall mean the work of authorship, whether in Source or Object form, made\navailable under the License, as indicated by a copyright notice that is included\nin or attached to the work (an example is provided in the Appendix below).\n\n\"Derivative Works\" shall mean any work, whether in Source or Object form, that\nis based on (or derived from) the Work and for which the editorial revisions,\nannotations, elaborations, or other modifications represent, as a whole, an\noriginal work of authorship. For the purposes of this License, Derivative Works\nshall not include works that remain separable from, or merely link (or bind by\nname) to the interfaces of, the Work and Derivative Works thereof.\n\n\"Contribution\" shall mean any work of authorship, including the original version\nof the Work and any modifications or additions to that Work or Derivative Works\nthereof, that is intentionally submitted to Licensor for inclusion in the Work\nby the copyright owner or by an individual or Legal Entity authorized to submit\non behalf of the copyright owner. For the purposes of this definition,\n\"submitted\" means any form of electronic, verbal, or written communication sent\nto the Licensor or its representatives, including but not limited to\ncommunication on electronic mailing lists, source code control systems, and\nissue tracking systems that are managed by, or on behalf of, the Licensor for\nthe purpose of discussing and improving the Work, but excluding communication\nthat is conspicuously marked or otherwise designated in writing by the copyright\nowner as \"Not a Contribution.\"\n\n\"Contributor\" shall mean Licensor and any individual or Legal Entity on behalf\nof whom a Contribution has been received by Licensor and subsequently\nincorporated within the Work.\n\n2. Grant of Copyright License.\n\nSubject to the terms and conditions of this License, each Contributor hereby\ngrants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,\nirrevocable copyright license to reproduce, prepare Derivative Works of,\npublicly display, publicly perform, sublicense, and distribute the Work and such\nDerivative Works in Source or Object form.\n\n3. Grant of Patent License.\n\nSubject to the terms and conditions of this License, each Contributor hereby\ngrants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,\nirrevocable (except as stated in this section) patent license to make, have\nmade, use, offer to sell, sell, import, and otherwise transfer the Work, where\nsuch license applies only to those patent claims licensable by such Contributor\nthat are necessarily infringed by their Contribution(s) alone or by combination\nof their Contribution(s) with the Work to which such Contribution(s) was\nsubmitted. If You institute patent litigation against any entity (including a\ncross-claim or counterclaim in a lawsuit) alleging that the Work or a\nContribution incorporated within the Work constitutes direct or contributory\npatent infringement, then any patent licenses granted to You under this License\nfor that Work shall terminate as of the date such litigation is filed.\n\n4. Redistribution.\n\nYou may reproduce and distribute copies of the Work or Derivative Works thereof\nin any medium, with or without modifications, and in Source or Object form,\nprovided that You meet the following conditions:\n\nYou must give any other recipients of the Work or Derivative Works a copy of\nthis License; and\nYou must cause any modified files to carry prominent notices stating that You\nchanged the files; and\nYou must retain, in the Source form of any Derivative Works that You distribute,\nall copyright, patent, trademark, and attribution notices from the Source form\nof the Work, excluding those notices that do not pertain to any part of the\nDerivative Works; and\nIf the Work includes a \"NOTICE\" text file as part of its distribution, then any\nDerivative Works that You distribute must include a readable copy of the\nattribution notices contained within such NOTICE file, excluding those notices\nthat do not pertain to any part of the Derivative Works, in at least one of the\nfollowing places: within a NOTICE text file distributed as part of the\nDerivative Works; within the Source form or documentation, if provided along\nwith the Derivative Works; or, within a display generated by the Derivative\nWorks, if and wherever such third-party notices normally appear. The contents of\nthe NOTICE file are for informational purposes only and do not modify the\nLicense. You may add Your own attribution notices within Derivative Works that\nYou distribute, alongside or as an addendum to the NOTICE text from the Work,\nprovided that such additional attribution notices cannot be construed as\nmodifying the License.\nYou may add Your own copyright statement to Your modifications and may provide\nadditional or different license terms and conditions for use, reproduction, or\ndistribution of Your modifications, or for any such Derivative Works as a whole,\nprovided Your use, reproduction, and distribution of the Work otherwise complies\nwith the conditions stated in this License.\n\n5. Submission of Contributions.\n\nUnless You explicitly state otherwise, any Contribution intentionally submitted\nfor inclusion in the Work by You to the Licensor shall be under the terms and\nconditions of this License, without any additional terms or conditions.\nNotwithstanding the above, nothing herein shall supersede or modify the terms of\nany separate license agreement you may have executed with Licensor regarding\nsuch Contributions.\n\n6. Trademarks.\n\nThis License does not grant permission to use the trade names, trademarks,\nservice marks, or product names of the Licensor, except as required for\nreasonable and customary use in describing the origin of the Work and\nreproducing the content of the NOTICE file.\n\n7. Disclaimer of Warranty.\n\nUnless required by applicable law or agreed to in writing, Licensor provides the\nWork (and each Contributor provides its Contributions) on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied,\nincluding, without limitation, any warranties or conditions of TITLE,\nNON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are\nsolely responsible for determining the appropriateness of using or\nredistributing the Work and assume any risks associated with Your exercise of\npermissions under this License.\n\n8. Limitation of Liability.\n\nIn no event and under no legal theory, whether in tort (including negligence),\ncontract, or otherwise, unless required by applicable law (such as deliberate\nand grossly negligent acts) or agreed to in writing, shall any Contributor be\nliable to You for damages, including any direct, indirect, special, incidental,\nor consequential damages of any character arising as a result of this License or\nout of the use or inability to use the Work (including but not limited to\ndamages for loss of goodwill, work stoppage, computer failure or malfunction, or\nany and all other commercial damages or losses), even if such Contributor has\nbeen advised of the possibility of such damages.\n\n9. Accepting Warranty or Additional Liability.\n\nWhile redistributing the Work or Derivative Works thereof, You may choose to\noffer, and charge a fee for, acceptance of support, warranty, indemnity, or\nother liability obligations and/or rights consistent with this License. However,\nin accepting such obligations, You may act only on Your own behalf and on Your\nsole responsibility, not on behalf of any other Contributor, and only if You\nagree to indemnify, defend, and hold each Contributor harmless for any liability\nincurred by, or claims asserted against, such Contributor by reason of your\naccepting any such warranty or additional liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache License to your work\n\nTo apply the Apache License to your work, attach the following boilerplate\nnotice, with the fields enclosed by brackets \"[]\" replaced with your own\nidentifying information. (Don't include the brackets!) The text should be\nenclosed in the appropriate comment syntax for the file format. We also\nrecommend that a file or class name and description of purpose be included on\nthe same \"printed page\" as the copyright notice for easier identification within\nthird-party archives.\n\n Copyright [yyyy] [name of copyright owner]\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n" }, - { - "name": "github.com/golang/protobuf/proto", - "path": "github.com/golang/protobuf/proto/LICENSE", - "licenseText": "Copyright 2010 The Go Authors. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n\n" - }, { "name": "github.com/golang/snappy", "path": "github.com/golang/snappy/LICENSE", @@ -635,18 +610,18 @@ "licenseText": "\n Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright [yyyy] [name of copyright owner]\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n" }, { - "name": "github.com/google/flatbuffers/go", - "path": "github.com/google/flatbuffers/go/LICENSE", + "name": "github.com/google/flatbuffers", + "path": "github.com/google/flatbuffers/LICENSE", "licenseText": "\n Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright [yyyy] [name of copyright owner]\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n" }, { - "name": "github.com/google/go-github/v74/github", - "path": "github.com/google/go-github/v74/github/LICENSE", + "name": "github.com/google/go-github/v74", + "path": "github.com/google/go-github/v74/LICENSE", "licenseText": "Copyright (c) 2013 The go-github AUTHORS. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" }, { - "name": "github.com/google/go-querystring/query", - "path": "github.com/google/go-querystring/query/LICENSE", + "name": "github.com/google/go-querystring", + "path": "github.com/google/go-querystring/LICENSE", "licenseText": "Copyright (c) 2013 Google. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" }, { @@ -660,8 +635,8 @@ "licenseText": "\n Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright [yyyy] [name of copyright owner]\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n" }, { - "name": "github.com/google/pprof/profile", - "path": "github.com/google/pprof/profile/LICENSE", + "name": "github.com/google/pprof", + "path": "github.com/google/pprof/LICENSE", "licenseText": "\n Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright [yyyy] [name of copyright owner]\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n" }, { @@ -670,8 +645,8 @@ "licenseText": "Copyright (c) 2009,2014 Google Inc. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" }, { - "name": "github.com/gorilla/css/scanner", - "path": "github.com/gorilla/css/scanner/LICENSE", + "name": "github.com/gorilla/css", + "path": "github.com/gorilla/css/LICENSE", "licenseText": "Copyright (c) 2023 The Gorilla Authors. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n\t * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n\t * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n\t * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n\n" }, { @@ -717,7 +692,7 @@ { "name": "github.com/hashicorp/go-version", "path": "github.com/hashicorp/go-version/LICENSE", - "licenseText": "Mozilla Public License, version 2.0\n\n1. Definitions\n\n1.1. “Contributor”\n\n means each individual or legal entity that creates, contributes to the\n creation of, or owns Covered Software.\n\n1.2. “Contributor Version”\n\n means the combination of the Contributions of others (if any) used by a\n Contributor and that particular Contributor’s Contribution.\n\n1.3. “Contribution”\n\n means Covered Software of a particular Contributor.\n\n1.4. “Covered Software”\n\n means Source Code Form to which the initial Contributor has attached the\n notice in Exhibit A, the Executable Form of such Source Code Form, and\n Modifications of such Source Code Form, in each case including portions\n thereof.\n\n1.5. “Incompatible With Secondary Licenses”\n means\n\n a. that the initial Contributor has attached the notice described in\n Exhibit B to the Covered Software; or\n\n b. that the Covered Software was made available under the terms of version\n 1.1 or earlier of the License, but not also under the terms of a\n Secondary License.\n\n1.6. “Executable Form”\n\n means any form of the work other than Source Code Form.\n\n1.7. “Larger Work”\n\n means a work that combines Covered Software with other material, in a separate\n file or files, that is not Covered Software.\n\n1.8. “License”\n\n means this document.\n\n1.9. “Licensable”\n\n means having the right to grant, to the maximum extent possible, whether at the\n time of the initial grant or subsequently, any and all of the rights conveyed by\n this License.\n\n1.10. “Modifications”\n\n means any of the following:\n\n a. any file in Source Code Form that results from an addition to, deletion\n from, or modification of the contents of Covered Software; or\n\n b. any new file in Source Code Form that contains any Covered Software.\n\n1.11. “Patent Claims” of a Contributor\n\n means any patent claim(s), including without limitation, method, process,\n and apparatus claims, in any patent Licensable by such Contributor that\n would be infringed, but for the grant of the License, by the making,\n using, selling, offering for sale, having made, import, or transfer of\n either its Contributions or its Contributor Version.\n\n1.12. “Secondary License”\n\n means either the GNU General Public License, Version 2.0, the GNU Lesser\n General Public License, Version 2.1, the GNU Affero General Public\n License, Version 3.0, or any later versions of those licenses.\n\n1.13. “Source Code Form”\n\n means the form of the work preferred for making modifications.\n\n1.14. “You” (or “Your”)\n\n means an individual or a legal entity exercising rights under this\n License. For legal entities, “You” includes any entity that controls, is\n controlled by, or is under common control with You. For purposes of this\n definition, “control” means (a) the power, direct or indirect, to cause\n the direction or management of such entity, whether by contract or\n otherwise, or (b) ownership of more than fifty percent (50%) of the\n outstanding shares or beneficial ownership of such entity.\n\n\n2. License Grants and Conditions\n\n2.1. Grants\n\n Each Contributor hereby grants You a world-wide, royalty-free,\n non-exclusive license:\n\n a. under intellectual property rights (other than patent or trademark)\n Licensable by such Contributor to use, reproduce, make available,\n modify, display, perform, distribute, and otherwise exploit its\n Contributions, either on an unmodified basis, with Modifications, or as\n part of a Larger Work; and\n\n b. under Patent Claims of such Contributor to make, use, sell, offer for\n sale, have made, import, and otherwise transfer either its Contributions\n or its Contributor Version.\n\n2.2. Effective Date\n\n The licenses granted in Section 2.1 with respect to any Contribution become\n effective for each Contribution on the date the Contributor first distributes\n such Contribution.\n\n2.3. Limitations on Grant Scope\n\n The licenses granted in this Section 2 are the only rights granted under this\n License. No additional rights or licenses will be implied from the distribution\n or licensing of Covered Software under this License. Notwithstanding Section\n 2.1(b) above, no patent license is granted by a Contributor:\n\n a. for any code that a Contributor has removed from Covered Software; or\n\n b. for infringements caused by: (i) Your and any other third party’s\n modifications of Covered Software, or (ii) the combination of its\n Contributions with other software (except as part of its Contributor\n Version); or\n\n c. under Patent Claims infringed by Covered Software in the absence of its\n Contributions.\n\n This License does not grant any rights in the trademarks, service marks, or\n logos of any Contributor (except as may be necessary to comply with the\n notice requirements in Section 3.4).\n\n2.4. Subsequent Licenses\n\n No Contributor makes additional grants as a result of Your choice to\n distribute the Covered Software under a subsequent version of this License\n (see Section 10.2) or under the terms of a Secondary License (if permitted\n under the terms of Section 3.3).\n\n2.5. Representation\n\n Each Contributor represents that the Contributor believes its Contributions\n are its original creation(s) or it has sufficient rights to grant the\n rights to its Contributions conveyed by this License.\n\n2.6. Fair Use\n\n This License is not intended to limit any rights You have under applicable\n copyright doctrines of fair use, fair dealing, or other equivalents.\n\n2.7. Conditions\n\n Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in\n Section 2.1.\n\n\n3. Responsibilities\n\n3.1. Distribution of Source Form\n\n All distribution of Covered Software in Source Code Form, including any\n Modifications that You create or to which You contribute, must be under the\n terms of this License. You must inform recipients that the Source Code Form\n of the Covered Software is governed by the terms of this License, and how\n they can obtain a copy of this License. You may not attempt to alter or\n restrict the recipients’ rights in the Source Code Form.\n\n3.2. Distribution of Executable Form\n\n If You distribute Covered Software in Executable Form then:\n\n a. such Covered Software must also be made available in Source Code Form,\n as described in Section 3.1, and You must inform recipients of the\n Executable Form how they can obtain a copy of such Source Code Form by\n reasonable means in a timely manner, at a charge no more than the cost\n of distribution to the recipient; and\n\n b. You may distribute such Executable Form under the terms of this License,\n or sublicense it under different terms, provided that the license for\n the Executable Form does not attempt to limit or alter the recipients’\n rights in the Source Code Form under this License.\n\n3.3. Distribution of a Larger Work\n\n You may create and distribute a Larger Work under terms of Your choice,\n provided that You also comply with the requirements of this License for the\n Covered Software. If the Larger Work is a combination of Covered Software\n with a work governed by one or more Secondary Licenses, and the Covered\n Software is not Incompatible With Secondary Licenses, this License permits\n You to additionally distribute such Covered Software under the terms of\n such Secondary License(s), so that the recipient of the Larger Work may, at\n their option, further distribute the Covered Software under the terms of\n either this License or such Secondary License(s).\n\n3.4. Notices\n\n You may not remove or alter the substance of any license notices (including\n copyright notices, patent notices, disclaimers of warranty, or limitations\n of liability) contained within the Source Code Form of the Covered\n Software, except that You may alter any license notices to the extent\n required to remedy known factual inaccuracies.\n\n3.5. Application of Additional Terms\n\n You may choose to offer, and to charge a fee for, warranty, support,\n indemnity or liability obligations to one or more recipients of Covered\n Software. However, You may do so only on Your own behalf, and not on behalf\n of any Contributor. You must make it absolutely clear that any such\n warranty, support, indemnity, or liability obligation is offered by You\n alone, and You hereby agree to indemnify every Contributor for any\n liability incurred by such Contributor as a result of warranty, support,\n indemnity or liability terms You offer. You may include additional\n disclaimers of warranty and limitations of liability specific to any\n jurisdiction.\n\n4. Inability to Comply Due to Statute or Regulation\n\n If it is impossible for You to comply with any of the terms of this License\n with respect to some or all of the Covered Software due to statute, judicial\n order, or regulation then You must: (a) comply with the terms of this License\n to the maximum extent possible; and (b) describe the limitations and the code\n they affect. Such description must be placed in a text file included with all\n distributions of the Covered Software under this License. Except to the\n extent prohibited by statute or regulation, such description must be\n sufficiently detailed for a recipient of ordinary skill to be able to\n understand it.\n\n5. Termination\n\n5.1. The rights granted under this License will terminate automatically if You\n fail to comply with any of its terms. However, if You become compliant,\n then the rights granted under this License from a particular Contributor\n are reinstated (a) provisionally, unless and until such Contributor\n explicitly and finally terminates Your grants, and (b) on an ongoing basis,\n if such Contributor fails to notify You of the non-compliance by some\n reasonable means prior to 60 days after You have come back into compliance.\n Moreover, Your grants from a particular Contributor are reinstated on an\n ongoing basis if such Contributor notifies You of the non-compliance by\n some reasonable means, this is the first time You have received notice of\n non-compliance with this License from such Contributor, and You become\n compliant prior to 30 days after Your receipt of the notice.\n\n5.2. If You initiate litigation against any entity by asserting a patent\n infringement claim (excluding declaratory judgment actions, counter-claims,\n and cross-claims) alleging that a Contributor Version directly or\n indirectly infringes any patent, then the rights granted to You by any and\n all Contributors for the Covered Software under Section 2.1 of this License\n shall terminate.\n\n5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user\n license agreements (excluding distributors and resellers) which have been\n validly granted by You or Your distributors under this License prior to\n termination shall survive termination.\n\n6. Disclaimer of Warranty\n\n Covered Software is provided under this License on an “as is” basis, without\n warranty of any kind, either expressed, implied, or statutory, including,\n without limitation, warranties that the Covered Software is free of defects,\n merchantable, fit for a particular purpose or non-infringing. The entire\n risk as to the quality and performance of the Covered Software is with You.\n Should any Covered Software prove defective in any respect, You (not any\n Contributor) assume the cost of any necessary servicing, repair, or\n correction. This disclaimer of warranty constitutes an essential part of this\n License. No use of any Covered Software is authorized under this License\n except under this disclaimer.\n\n7. Limitation of Liability\n\n Under no circumstances and under no legal theory, whether tort (including\n negligence), contract, or otherwise, shall any Contributor, or anyone who\n distributes Covered Software as permitted above, be liable to You for any\n direct, indirect, special, incidental, or consequential damages of any\n character including, without limitation, damages for lost profits, loss of\n goodwill, work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses, even if such party shall have been\n informed of the possibility of such damages. This limitation of liability\n shall not apply to liability for death or personal injury resulting from such\n party’s negligence to the extent applicable law prohibits such limitation.\n Some jurisdictions do not allow the exclusion or limitation of incidental or\n consequential damages, so this exclusion and limitation may not apply to You.\n\n8. Litigation\n\n Any litigation relating to this License may be brought only in the courts of\n a jurisdiction where the defendant maintains its principal place of business\n and such litigation shall be governed by laws of that jurisdiction, without\n reference to its conflict-of-law provisions. Nothing in this Section shall\n prevent a party’s ability to bring cross-claims or counter-claims.\n\n9. Miscellaneous\n\n This License represents the complete agreement concerning the subject matter\n hereof. If any provision of this License is held to be unenforceable, such\n provision shall be reformed only to the extent necessary to make it\n enforceable. Any law or regulation which provides that the language of a\n contract shall be construed against the drafter shall not be used to construe\n this License against a Contributor.\n\n\n10. Versions of the License\n\n10.1. New Versions\n\n Mozilla Foundation is the license steward. Except as provided in Section\n 10.3, no one other than the license steward has the right to modify or\n publish new versions of this License. Each version will be given a\n distinguishing version number.\n\n10.2. Effect of New Versions\n\n You may distribute the Covered Software under the terms of the version of\n the License under which You originally received the Covered Software, or\n under the terms of any subsequent version published by the license\n steward.\n\n10.3. Modified Versions\n\n If you create software not governed by this License, and you want to\n create a new license for such software, you may create and use a modified\n version of this License if you rename the license and remove any\n references to the name of the license steward (except to note that such\n modified license differs from this License).\n\n10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses\n If You choose to distribute Source Code Form that is Incompatible With\n Secondary Licenses under the terms of this version of the License, the\n notice described in Exhibit B of this License must be attached.\n\nExhibit A - Source Code Form License Notice\n\n This Source Code Form is subject to the\n terms of the Mozilla Public License, v.\n 2.0. If a copy of the MPL was not\n distributed with this file, You can\n obtain one at\n http://mozilla.org/MPL/2.0/.\n\nIf it is not possible or desirable to put the notice in a particular file, then\nYou may include the notice in a location (such as a LICENSE file in a relevant\ndirectory) where a recipient would be likely to look for such a notice.\n\nYou may add additional accurate notices of copyright ownership.\n\nExhibit B - “Incompatible With Secondary Licenses” Notice\n\n This Source Code Form is “Incompatible\n With Secondary Licenses”, as defined by\n the Mozilla Public License, v. 2.0.\n\n" + "licenseText": "Copyright IBM Corp. 2014, 2025\n\nMozilla Public License, version 2.0\n\n1. Definitions\n\n1.1. “Contributor”\n\n means each individual or legal entity that creates, contributes to the\n creation of, or owns Covered Software.\n\n1.2. “Contributor Version”\n\n means the combination of the Contributions of others (if any) used by a\n Contributor and that particular Contributor’s Contribution.\n\n1.3. “Contribution”\n\n means Covered Software of a particular Contributor.\n\n1.4. “Covered Software”\n\n means Source Code Form to which the initial Contributor has attached the\n notice in Exhibit A, the Executable Form of such Source Code Form, and\n Modifications of such Source Code Form, in each case including portions\n thereof.\n\n1.5. “Incompatible With Secondary Licenses”\n means\n\n a. that the initial Contributor has attached the notice described in\n Exhibit B to the Covered Software; or\n\n b. that the Covered Software was made available under the terms of version\n 1.1 or earlier of the License, but not also under the terms of a\n Secondary License.\n\n1.6. “Executable Form”\n\n means any form of the work other than Source Code Form.\n\n1.7. “Larger Work”\n\n means a work that combines Covered Software with other material, in a separate\n file or files, that is not Covered Software.\n\n1.8. “License”\n\n means this document.\n\n1.9. “Licensable”\n\n means having the right to grant, to the maximum extent possible, whether at the\n time of the initial grant or subsequently, any and all of the rights conveyed by\n this License.\n\n1.10. “Modifications”\n\n means any of the following:\n\n a. any file in Source Code Form that results from an addition to, deletion\n from, or modification of the contents of Covered Software; or\n\n b. any new file in Source Code Form that contains any Covered Software.\n\n1.11. “Patent Claims” of a Contributor\n\n means any patent claim(s), including without limitation, method, process,\n and apparatus claims, in any patent Licensable by such Contributor that\n would be infringed, but for the grant of the License, by the making,\n using, selling, offering for sale, having made, import, or transfer of\n either its Contributions or its Contributor Version.\n\n1.12. “Secondary License”\n\n means either the GNU General Public License, Version 2.0, the GNU Lesser\n General Public License, Version 2.1, the GNU Affero General Public\n License, Version 3.0, or any later versions of those licenses.\n\n1.13. “Source Code Form”\n\n means the form of the work preferred for making modifications.\n\n1.14. “You” (or “Your”)\n\n means an individual or a legal entity exercising rights under this\n License. For legal entities, “You” includes any entity that controls, is\n controlled by, or is under common control with You. For purposes of this\n definition, “control” means (a) the power, direct or indirect, to cause\n the direction or management of such entity, whether by contract or\n otherwise, or (b) ownership of more than fifty percent (50%) of the\n outstanding shares or beneficial ownership of such entity.\n\n\n2. License Grants and Conditions\n\n2.1. Grants\n\n Each Contributor hereby grants You a world-wide, royalty-free,\n non-exclusive license:\n\n a. under intellectual property rights (other than patent or trademark)\n Licensable by such Contributor to use, reproduce, make available,\n modify, display, perform, distribute, and otherwise exploit its\n Contributions, either on an unmodified basis, with Modifications, or as\n part of a Larger Work; and\n\n b. under Patent Claims of such Contributor to make, use, sell, offer for\n sale, have made, import, and otherwise transfer either its Contributions\n or its Contributor Version.\n\n2.2. Effective Date\n\n The licenses granted in Section 2.1 with respect to any Contribution become\n effective for each Contribution on the date the Contributor first distributes\n such Contribution.\n\n2.3. Limitations on Grant Scope\n\n The licenses granted in this Section 2 are the only rights granted under this\n License. No additional rights or licenses will be implied from the distribution\n or licensing of Covered Software under this License. Notwithstanding Section\n 2.1(b) above, no patent license is granted by a Contributor:\n\n a. for any code that a Contributor has removed from Covered Software; or\n\n b. for infringements caused by: (i) Your and any other third party’s\n modifications of Covered Software, or (ii) the combination of its\n Contributions with other software (except as part of its Contributor\n Version); or\n\n c. under Patent Claims infringed by Covered Software in the absence of its\n Contributions.\n\n This License does not grant any rights in the trademarks, service marks, or\n logos of any Contributor (except as may be necessary to comply with the\n notice requirements in Section 3.4).\n\n2.4. Subsequent Licenses\n\n No Contributor makes additional grants as a result of Your choice to\n distribute the Covered Software under a subsequent version of this License\n (see Section 10.2) or under the terms of a Secondary License (if permitted\n under the terms of Section 3.3).\n\n2.5. Representation\n\n Each Contributor represents that the Contributor believes its Contributions\n are its original creation(s) or it has sufficient rights to grant the\n rights to its Contributions conveyed by this License.\n\n2.6. Fair Use\n\n This License is not intended to limit any rights You have under applicable\n copyright doctrines of fair use, fair dealing, or other equivalents.\n\n2.7. Conditions\n\n Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in\n Section 2.1.\n\n\n3. Responsibilities\n\n3.1. Distribution of Source Form\n\n All distribution of Covered Software in Source Code Form, including any\n Modifications that You create or to which You contribute, must be under the\n terms of this License. You must inform recipients that the Source Code Form\n of the Covered Software is governed by the terms of this License, and how\n they can obtain a copy of this License. You may not attempt to alter or\n restrict the recipients’ rights in the Source Code Form.\n\n3.2. Distribution of Executable Form\n\n If You distribute Covered Software in Executable Form then:\n\n a. such Covered Software must also be made available in Source Code Form,\n as described in Section 3.1, and You must inform recipients of the\n Executable Form how they can obtain a copy of such Source Code Form by\n reasonable means in a timely manner, at a charge no more than the cost\n of distribution to the recipient; and\n\n b. You may distribute such Executable Form under the terms of this License,\n or sublicense it under different terms, provided that the license for\n the Executable Form does not attempt to limit or alter the recipients’\n rights in the Source Code Form under this License.\n\n3.3. Distribution of a Larger Work\n\n You may create and distribute a Larger Work under terms of Your choice,\n provided that You also comply with the requirements of this License for the\n Covered Software. If the Larger Work is a combination of Covered Software\n with a work governed by one or more Secondary Licenses, and the Covered\n Software is not Incompatible With Secondary Licenses, this License permits\n You to additionally distribute such Covered Software under the terms of\n such Secondary License(s), so that the recipient of the Larger Work may, at\n their option, further distribute the Covered Software under the terms of\n either this License or such Secondary License(s).\n\n3.4. Notices\n\n You may not remove or alter the substance of any license notices (including\n copyright notices, patent notices, disclaimers of warranty, or limitations\n of liability) contained within the Source Code Form of the Covered\n Software, except that You may alter any license notices to the extent\n required to remedy known factual inaccuracies.\n\n3.5. Application of Additional Terms\n\n You may choose to offer, and to charge a fee for, warranty, support,\n indemnity or liability obligations to one or more recipients of Covered\n Software. However, You may do so only on Your own behalf, and not on behalf\n of any Contributor. You must make it absolutely clear that any such\n warranty, support, indemnity, or liability obligation is offered by You\n alone, and You hereby agree to indemnify every Contributor for any\n liability incurred by such Contributor as a result of warranty, support,\n indemnity or liability terms You offer. You may include additional\n disclaimers of warranty and limitations of liability specific to any\n jurisdiction.\n\n4. Inability to Comply Due to Statute or Regulation\n\n If it is impossible for You to comply with any of the terms of this License\n with respect to some or all of the Covered Software due to statute, judicial\n order, or regulation then You must: (a) comply with the terms of this License\n to the maximum extent possible; and (b) describe the limitations and the code\n they affect. Such description must be placed in a text file included with all\n distributions of the Covered Software under this License. Except to the\n extent prohibited by statute or regulation, such description must be\n sufficiently detailed for a recipient of ordinary skill to be able to\n understand it.\n\n5. Termination\n\n5.1. The rights granted under this License will terminate automatically if You\n fail to comply with any of its terms. However, if You become compliant,\n then the rights granted under this License from a particular Contributor\n are reinstated (a) provisionally, unless and until such Contributor\n explicitly and finally terminates Your grants, and (b) on an ongoing basis,\n if such Contributor fails to notify You of the non-compliance by some\n reasonable means prior to 60 days after You have come back into compliance.\n Moreover, Your grants from a particular Contributor are reinstated on an\n ongoing basis if such Contributor notifies You of the non-compliance by\n some reasonable means, this is the first time You have received notice of\n non-compliance with this License from such Contributor, and You become\n compliant prior to 30 days after Your receipt of the notice.\n\n5.2. If You initiate litigation against any entity by asserting a patent\n infringement claim (excluding declaratory judgment actions, counter-claims,\n and cross-claims) alleging that a Contributor Version directly or\n indirectly infringes any patent, then the rights granted to You by any and\n all Contributors for the Covered Software under Section 2.1 of this License\n shall terminate.\n\n5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user\n license agreements (excluding distributors and resellers) which have been\n validly granted by You or Your distributors under this License prior to\n termination shall survive termination.\n\n6. Disclaimer of Warranty\n\n Covered Software is provided under this License on an “as is” basis, without\n warranty of any kind, either expressed, implied, or statutory, including,\n without limitation, warranties that the Covered Software is free of defects,\n merchantable, fit for a particular purpose or non-infringing. The entire\n risk as to the quality and performance of the Covered Software is with You.\n Should any Covered Software prove defective in any respect, You (not any\n Contributor) assume the cost of any necessary servicing, repair, or\n correction. This disclaimer of warranty constitutes an essential part of this\n License. No use of any Covered Software is authorized under this License\n except under this disclaimer.\n\n7. Limitation of Liability\n\n Under no circumstances and under no legal theory, whether tort (including\n negligence), contract, or otherwise, shall any Contributor, or anyone who\n distributes Covered Software as permitted above, be liable to You for any\n direct, indirect, special, incidental, or consequential damages of any\n character including, without limitation, damages for lost profits, loss of\n goodwill, work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses, even if such party shall have been\n informed of the possibility of such damages. This limitation of liability\n shall not apply to liability for death or personal injury resulting from such\n party’s negligence to the extent applicable law prohibits such limitation.\n Some jurisdictions do not allow the exclusion or limitation of incidental or\n consequential damages, so this exclusion and limitation may not apply to You.\n\n8. Litigation\n\n Any litigation relating to this License may be brought only in the courts of\n a jurisdiction where the defendant maintains its principal place of business\n and such litigation shall be governed by laws of that jurisdiction, without\n reference to its conflict-of-law provisions. Nothing in this Section shall\n prevent a party’s ability to bring cross-claims or counter-claims.\n\n9. Miscellaneous\n\n This License represents the complete agreement concerning the subject matter\n hereof. If any provision of this License is held to be unenforceable, such\n provision shall be reformed only to the extent necessary to make it\n enforceable. Any law or regulation which provides that the language of a\n contract shall be construed against the drafter shall not be used to construe\n this License against a Contributor.\n\n\n10. Versions of the License\n\n10.1. New Versions\n\n Mozilla Foundation is the license steward. Except as provided in Section\n 10.3, no one other than the license steward has the right to modify or\n publish new versions of this License. Each version will be given a\n distinguishing version number.\n\n10.2. Effect of New Versions\n\n You may distribute the Covered Software under the terms of the version of\n the License under which You originally received the Covered Software, or\n under the terms of any subsequent version published by the license\n steward.\n\n10.3. Modified Versions\n\n If you create software not governed by this License, and you want to\n create a new license for such software, you may create and use a modified\n version of this License if you rename the license and remove any\n references to the name of the license steward (except to note that such\n modified license differs from this License).\n\n10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses\n If You choose to distribute Source Code Form that is Incompatible With\n Secondary Licenses under the terms of this version of the License, the\n notice described in Exhibit B of this License must be attached.\n\nExhibit A - Source Code Form License Notice\n\n This Source Code Form is subject to the\n terms of the Mozilla Public License, v.\n 2.0. If a copy of the MPL was not\n distributed with this file, You can\n obtain one at\n http://mozilla.org/MPL/2.0/.\n\nIf it is not possible or desirable to put the notice in a particular file, then\nYou may include the notice in a location (such as a LICENSE file in a relevant\ndirectory) where a recipient would be likely to look for such a notice.\n\nYou may add additional accurate notices of copyright ownership.\n\nExhibit B - “Incompatible With Secondary Licenses” Notice\n\n This Source Code Form is “Incompatible\n With Secondary Licenses”, as defined by\n the Mozilla Public License, v. 2.0.\n\n" }, { "name": "github.com/hashicorp/golang-lru/v2", @@ -740,8 +715,8 @@ "licenseText": "The MIT License (MIT)\n\nCopyright (c) 2015 Jay Taylor\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n" }, { - "name": "github.com/jbenet/go-context/io", - "path": "github.com/jbenet/go-context/io/LICENSE", + "name": "github.com/jbenet/go-context", + "path": "github.com/jbenet/go-context/LICENSE", "licenseText": "The MIT License (MIT)\n\nCopyright (c) 2014 Juan Batiz-Benet\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n" }, { @@ -799,6 +774,11 @@ "path": "github.com/klauspost/cpuid/v2/LICENSE", "licenseText": "The MIT License (MIT)\n\nCopyright (c) 2015 Klaus Post\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n" }, + { + "name": "github.com/klauspost/crc32", + "path": "github.com/klauspost/crc32/LICENSE", + "licenseText": "Copyright (c) 2012 The Go Authors. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" + }, { "name": "github.com/klauspost/pgzip", "path": "github.com/klauspost/pgzip/LICENSE", @@ -806,8 +786,8 @@ }, { "name": "github.com/lib/pq", - "path": "github.com/lib/pq/LICENSE.md", - "licenseText": "Copyright (c) 2011-2013, 'pq' Contributors\nPortions Copyright (C) 2011 Blake Mizerany\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n" + "path": "github.com/lib/pq/LICENSE", + "licenseText": "MIT License\n\nCopyright (c) 2011-2013, 'pq' Contributors. Portions Copyright (c) 2011 Blake Mizerany\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" }, { "name": "github.com/libdns/libdns", @@ -820,8 +800,8 @@ "licenseText": "Copyright (c) 2016 Mail.Ru Group\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n" }, { - "name": "github.com/markbates/going/defaults", - "path": "github.com/markbates/going/defaults/LICENSE.txt", + "name": "github.com/markbates/going", + "path": "github.com/markbates/going/LICENSE.txt", "licenseText": "Copyright (c) 2014 Mark Bates\n\nMIT License\n\nPermission is hereby granted, free of charge, to any person obtaining\na copy of this software and associated documentation files (the\n\"Software\"), to deal in the Software without restriction, including\nwithout limitation the rights to use, copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the Software, and to\npermit persons to whom the Software is furnished to do so, subject to\nthe following conditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE\nLIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION\nWITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n" }, { @@ -875,8 +855,8 @@ "licenseText": "Copyright (c) 2012 The Go Authors. All rights reserved.\nCopyright (c) Microsoft Corporation.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" }, { - "name": "github.com/microsoft/go-mssqldb/internal/github.com/swisscom/mssql-always-encrypted/pkg", - "path": "github.com/microsoft/go-mssqldb/internal/github.com/swisscom/mssql-always-encrypted/pkg/LICENSE.txt", + "name": "github.com/microsoft/go-mssqldb/internal/github.com/swisscom/mssql-always-encrypted", + "path": "github.com/microsoft/go-mssqldb/internal/github.com/swisscom/mssql-always-encrypted/LICENSE.txt", "licenseText": "Copyright (c) 2021 Swisscom (Switzerland) Ltd\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of\nthis software and associated documentation files (the \"Software\"), to deal in\nthe Software without restriction, including without limitation the rights to\nuse, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software is furnished to do so,\nsubject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS\nFOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR\nCOPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER\nIN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN\nCONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n\n\n" }, { @@ -930,13 +910,13 @@ "licenseText": "Copyright (c) 2011, Open Knowledge Foundation Ltd.\nAll rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n Redistributions of source code must retain the above copyright\n notice, this list of conditions and the following disclaimer.\n\n Redistributions in binary form must reproduce the above copyright\n notice, this list of conditions and the following disclaimer in\n the documentation and/or other materials provided with the\n distribution.\n\n Neither the name of the Open Knowledge Foundation Ltd. nor the\n names of its contributors may be used to endorse or promote\n products derived from this software without specific prior written\n permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nHOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" }, { - "name": "github.com/nektos/act/pkg", - "path": "github.com/nektos/act/pkg/LICENSE", + "name": "github.com/nektos/act", + "path": "github.com/nektos/act/LICENSE", "licenseText": "MIT License\n\nCopyright (c) 2022 The Gitea Authors\nCopyright (c) 2019\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" }, { - "name": "github.com/niklasfasching/go-org/org", - "path": "github.com/niklasfasching/go-org/org/LICENSE", + "name": "github.com/niklasfasching/go-org", + "path": "github.com/niklasfasching/go-org/LICENSE", "licenseText": "MIT License\n\nCopyright (c) 2018 Niklas Fasching\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" }, { @@ -980,8 +960,8 @@ "licenseText": "\n Apache License\n Version 2.0, January 2004\n https://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n Copyright 2019, 2020 OCI Contributors\n Copyright 2016 Docker, Inc.\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n https://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n" }, { - "name": "github.com/opencontainers/image-spec/specs-go", - "path": "github.com/opencontainers/image-spec/specs-go/LICENSE", + "name": "github.com/opencontainers/image-spec", + "path": "github.com/opencontainers/image-spec/LICENSE", "licenseText": "\n Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n Copyright 2016 The Linux Foundation.\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n" }, { @@ -1005,8 +985,8 @@ "licenseText": "Copyright (c) 2015, Dave Cheney \u003cdave@cheney.net\u003e\nAll rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are met:\n\n* Redistributions of source code must retain the above copyright notice, this\n list of conditions and the following disclaimer.\n\n* Redistributions in binary form must reproduce the above copyright notice,\n this list of conditions and the following disclaimer in the documentation\n and/or other materials provided with the distribution.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\nAND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\nIMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\nDISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE\nFOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL\nDAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR\nSERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER\nCAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,\nOR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" }, { - "name": "github.com/pmezard/go-difflib/difflib", - "path": "github.com/pmezard/go-difflib/difflib/LICENSE", + "name": "github.com/pmezard/go-difflib", + "path": "github.com/pmezard/go-difflib/LICENSE", "licenseText": "Copyright (c) 2013, Patrick Mezard\nAll rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n Redistributions in binary form must reproduce the above copyright\nnotice, this list of conditions and the following disclaimer in the\ndocumentation and/or other materials provided with the distribution.\n The names of its contributors may not be used to endorse or promote\nproducts derived from this software without specific prior written\npermission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS\nIS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED\nTO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A\nPARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nHOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED\nTO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR\nPROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF\nLIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING\nNEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS\nSOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" }, { @@ -1015,18 +995,18 @@ "licenseText": "\n Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright [yyyy] [name of copyright owner]\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n" }, { - "name": "github.com/prometheus/client_golang/internal/github.com/golang/gddo/httputil", - "path": "github.com/prometheus/client_golang/internal/github.com/golang/gddo/httputil/LICENSE", - "licenseText": "Copyright (c) 2013 The Go Authors. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" - }, - { - "name": "github.com/prometheus/client_golang/prometheus", - "path": "github.com/prometheus/client_golang/prometheus/LICENSE", + "name": "github.com/prometheus/client_golang", + "path": "github.com/prometheus/client_golang/LICENSE", "licenseText": " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright [yyyy] [name of copyright owner]\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n" }, { - "name": "github.com/prometheus/client_model/go", - "path": "github.com/prometheus/client_model/go/LICENSE", + "name": "github.com/prometheus/client_golang/internal/github.com/golang/gddo", + "path": "github.com/prometheus/client_golang/internal/github.com/golang/gddo/LICENSE", + "licenseText": "Copyright (c) 2013 The Go Authors. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" + }, + { + "name": "github.com/prometheus/client_model", + "path": "github.com/prometheus/client_model/LICENSE", "licenseText": " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright [yyyy] [name of copyright owner]\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n" }, { @@ -1080,10 +1060,15 @@ "licenseText": "\n Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright [yyyy] [name of copyright owner]\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n" }, { - "name": "github.com/sergi/go-diff/diffmatchpatch", - "path": "github.com/sergi/go-diff/diffmatchpatch/LICENSE", + "name": "github.com/sergi/go-diff", + "path": "github.com/sergi/go-diff/LICENSE", "licenseText": "Copyright (c) 2012-2016 The go-diff Authors. All rights reserved.\n\nPermission is hereby granted, free of charge, to any person obtaining a\ncopy of this software and associated documentation files (the \"Software\"),\nto deal in the Software without restriction, including without limitation\nthe rights to use, copy, modify, merge, publish, distribute, sublicense,\nand/or sell copies of the Software, and to permit persons to whom the\nSoftware is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included\nin all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS\nOR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\nFROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n\n" }, + { + "name": "github.com/shopspring/decimal", + "path": "github.com/shopspring/decimal/LICENSE", + "licenseText": "The MIT License (MIT)\n\nCopyright (c) 2015 Spring, Inc.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n\n- Based on https://github.com/oguzbilgic/fpd, which has the following license:\n\"\"\"\nThe MIT License (MIT)\n\nCopyright (c) 2013 Oguz Bilgic\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of\nthis software and associated documentation files (the \"Software\"), to deal in\nthe Software without restriction, including without limitation the rights to\nuse, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software is furnished to do so,\nsubject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS\nFOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR\nCOPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER\nIN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN\nCONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n\"\"\"\n" + }, { "name": "github.com/sirupsen/logrus", "path": "github.com/sirupsen/logrus/LICENSE", @@ -1096,8 +1081,13 @@ }, { "name": "github.com/sorairolake/lzip-go", - "path": "github.com/sorairolake/lzip-go/LICENSE", - "licenseText": " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright [yyyy] [name of copyright owner]\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n\n---\n\nMIT License\n\nCopyright (c) 2024 Shun Sakai\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" + "path": "github.com/sorairolake/lzip-go/LICENSE-APACHE", + "licenseText": " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright [yyyy] [name of copyright owner]\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n" + }, + { + "name": "github.com/spf13/afero", + "path": "github.com/spf13/afero/LICENSE.txt", + "licenseText": " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n" }, { "name": "github.com/ssor/bom", @@ -1110,13 +1100,13 @@ "licenseText": "MIT License\n\nCopyright (c) 2012-2020 Mat Ryer, Tyler Bunnell and contributors.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" }, { - "name": "github.com/syndtr/goleveldb/leveldb", - "path": "github.com/syndtr/goleveldb/leveldb/LICENSE", + "name": "github.com/syndtr/goleveldb", + "path": "github.com/syndtr/goleveldb/LICENSE", "licenseText": "Copyright 2012 Suryandaru Triandana \u003csyndtr@gmail.com\u003e\nAll rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above copyright\nnotice, this list of conditions and the following disclaimer in the\ndocumentation and/or other materials provided with the distribution.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nHOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" }, { - "name": "github.com/tinylib/msgp/msgp", - "path": "github.com/tinylib/msgp/msgp/LICENSE", + "name": "github.com/tinylib/msgp", + "path": "github.com/tinylib/msgp/LICENSE", "licenseText": "Copyright (c) 2014 Philip Hofer\nPortions Copyright (c) 2009 The Go Authors (license at http://golang.org) where indicated\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE." }, { @@ -1144,11 +1134,6 @@ "path": "github.com/urfave/cli/v3/LICENSE", "licenseText": "MIT License\n\nCopyright (c) 2023 urfave/cli maintainers\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" }, - { - "name": "github.com/valyala/fastjson", - "path": "github.com/valyala/fastjson/LICENSE", - "licenseText": "The MIT License (MIT)\n\nCopyright (c) 2018 Aliaksandr Valialkin\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n" - }, { "name": "github.com/wneessen/go-mail", "path": "github.com/wneessen/go-mail/LICENSE", @@ -1169,6 +1154,11 @@ "path": "github.com/xanzy/ssh-agent/LICENSE", "licenseText": " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"{}\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright {yyyy} {name of copyright owner}\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n\n" }, + { + "name": "github.com/xi2/xz", + "path": "github.com/xi2/xz/LICENSE", + "licenseText": "Licensing of github.com/xi2/xz\n==============================\n\n This Go package is a modified version of\n\n XZ Embedded \u003chttp://tukaani.org/xz/embedded.html\u003e\n\n The contents of the testdata directory are modified versions of\n the test files from\n\n XZ Utils \u003chttp://tukaani.org/xz/\u003e\n\n All the files in this package have been written by Michael Cross,\n Lasse Collin and/or Igor PavLov. All these files have been put\n into the public domain. You can do whatever you want with these\n files.\n\n This software is provided \"as is\", without any warranty.\n" + }, { "name": "github.com/yohcop/openid-go", "path": "github.com/yohcop/openid-go/LICENSE", @@ -1217,13 +1207,28 @@ { "name": "go.uber.org/zap", "path": "go.uber.org/zap/LICENSE", - "licenseText": "Copyright (c) 2016-2017 Uber Technologies, Inc.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n" + "licenseText": "Copyright (c) 2016-2024 Uber Technologies, Inc.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n" }, { - "name": "go.uber.org/zap/exp/zapslog", - "path": "go.uber.org/zap/exp/zapslog/LICENSE", + "name": "go.uber.org/zap/exp", + "path": "go.uber.org/zap/exp/LICENSE", "licenseText": "Copyright (c) 2016-2024 Uber Technologies, Inc.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n" }, + { + "name": "go.yaml.in/yaml/v2", + "path": "go.yaml.in/yaml/v2/LICENSE", + "licenseText": " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"{}\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright {yyyy} {name of copyright owner}\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n" + }, + { + "name": "go.yaml.in/yaml/v3", + "path": "go.yaml.in/yaml/v3/LICENSE", + "licenseText": "\nThis project is covered by two different licenses: MIT and Apache.\n\n#### MIT License ####\n\nThe following files were ported to Go from C files of libyaml, and thus\nare still covered by their original MIT license, with the additional\ncopyright staring in 2011 when the project was ported over:\n\n apic.go emitterc.go parserc.go readerc.go scannerc.go\n writerc.go yamlh.go yamlprivateh.go\n\nCopyright (c) 2006-2010 Kirill Simonov\nCopyright (c) 2006-2011 Kirill Simonov\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of\nthis software and associated documentation files (the \"Software\"), to deal in\nthe Software without restriction, including without limitation the rights to\nuse, copy, modify, merge, publish, distribute, sublicense, and/or sell copies\nof the Software, and to permit persons to whom the Software is furnished to do\nso, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n### Apache License ###\n\nAll the remaining project files are covered by the Apache license:\n\nCopyright (c) 2011-2019 Canonical Ltd\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n" + }, + { + "name": "go.yaml.in/yaml/v4", + "path": "go.yaml.in/yaml/v4/LICENSE", + "licenseText": "\nThis project is covered by two different licenses: MIT and Apache.\n\n#### MIT License ####\n\nThe following files were ported to Go from C files of libyaml, and thus\nare still covered by their original MIT license, with the additional\ncopyright staring in 2011 when the project was ported over:\n\n apic.go emitterc.go parserc.go readerc.go scannerc.go\n writerc.go yamlh.go yamlprivateh.go\n\nCopyright (c) 2006-2010 Kirill Simonov\nCopyright (c) 2006-2011 Kirill Simonov\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of\nthis software and associated documentation files (the \"Software\"), to deal in\nthe Software without restriction, including without limitation the rights to\nuse, copy, modify, merge, publish, distribute, sublicense, and/or sell copies\nof the Software, and to permit persons to whom the Software is furnished to do\nso, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n### Apache License ###\n\nAll the remaining project files are covered by the Apache license:\n\nCopyright (c) 2011-2019 Canonical Ltd\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n" + }, { "name": "go4.org", "path": "go4.org/LICENSE", @@ -1240,8 +1245,8 @@ "licenseText": "Copyright 2009 The Go Authors.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Google LLC nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" }, { - "name": "golang.org/x/mod/semver", - "path": "golang.org/x/mod/semver/LICENSE", + "name": "golang.org/x/mod", + "path": "golang.org/x/mod/LICENSE", "licenseText": "Copyright 2009 The Go Authors.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Google LLC nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" }, { @@ -1270,13 +1275,13 @@ "licenseText": "Copyright 2009 The Go Authors.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Google LLC nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" }, { - "name": "golang.org/x/time/rate", - "path": "golang.org/x/time/rate/LICENSE", + "name": "golang.org/x/time", + "path": "golang.org/x/time/LICENSE", "licenseText": "Copyright 2009 The Go Authors.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Google LLC nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" }, { - "name": "google.golang.org/genproto/googleapis/rpc/status", - "path": "google.golang.org/genproto/googleapis/rpc/status/LICENSE", + "name": "google.golang.org/genproto/googleapis/rpc", + "path": "google.golang.org/genproto/googleapis/rpc/LICENSE", "licenseText": "\n Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright [yyyy] [name of copyright owner]\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n" }, { diff --git a/tools/misspellings.csv b/assets/misspellings.csv similarity index 100% rename from tools/misspellings.csv rename to assets/misspellings.csv diff --git a/build.go b/build.go deleted file mode 100644 index e81ba54690b..00000000000 --- a/build.go +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -//go:build vendor - -package main - -// Libraries that are included to vendor utilities used during Makefile build. -// These libraries will not be included in a normal compilation. - -import ( - // for vet - _ "code.gitea.io/gitea-vet" -) diff --git a/build/backport-locales.go b/build/backport-locales.go deleted file mode 100644 index d112dd72bd5..00000000000 --- a/build/backport-locales.go +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright 2023 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -//go:build ignore - -package main - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - - "code.gitea.io/gitea/modules/container" - "code.gitea.io/gitea/modules/setting" -) - -func main() { - if len(os.Args) != 2 { - println("usage: backport-locales ") - println("eg: backport-locales release/v1.19") - os.Exit(1) - } - - mustNoErr := func(err error) { - if err != nil { - panic(err) - } - } - collectInis := func(ref string) map[string]setting.ConfigProvider { - inis := map[string]setting.ConfigProvider{} - err := filepath.WalkDir("options/locale", func(path string, d os.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() || !strings.HasSuffix(d.Name(), ".ini") { - return nil - } - cfg, err := setting.NewConfigProviderForLocale(path) - mustNoErr(err) - inis[path] = cfg - fmt.Printf("collecting: %s @ %s\n", path, ref) - return nil - }) - mustNoErr(err) - return inis - } - - // collect new locales from current working directory - inisNew := collectInis("HEAD") - - // switch to the target ref, and collect the old locales - cmd := exec.Command("git", "checkout", os.Args[1]) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - mustNoErr(cmd.Run()) - inisOld := collectInis(os.Args[1]) - - // use old en-US as the base, and copy the new translations to the old locales - enUsOld := inisOld["options/locale/locale_en-US.ini"] - brokenWarned := make(container.Set[string]) - for path, iniOld := range inisOld { - if iniOld == enUsOld { - continue - } - iniNew := inisNew[path] - if iniNew == nil { - continue - } - for _, secEnUS := range enUsOld.Sections() { - secOld := iniOld.Section(secEnUS.Name()) - secNew := iniNew.Section(secEnUS.Name()) - for _, keyEnUs := range secEnUS.Keys() { - if secNew.HasKey(keyEnUs.Name()) { - oldStr := secOld.Key(keyEnUs.Name()).String() - newStr := secNew.Key(keyEnUs.Name()).String() - broken := oldStr != "" && strings.Count(oldStr, "%") != strings.Count(newStr, "%") - broken = broken || strings.Contains(oldStr, "\n") || strings.Contains(oldStr, "\n") - if broken { - brokenWarned.Add(secOld.Name() + "." + keyEnUs.Name()) - fmt.Println("----") - fmt.Printf("WARNING: skip broken locale: %s , [%s] %s\n", path, secEnUS.Name(), keyEnUs.Name()) - fmt.Printf("\told: %s\n", strings.ReplaceAll(oldStr, "\n", "\\n")) - fmt.Printf("\tnew: %s\n", strings.ReplaceAll(newStr, "\n", "\\n")) - continue - } - secOld.Key(keyEnUs.Name()).SetValue(newStr) - } - } - } - mustNoErr(iniOld.SaveTo(path)) - } - - fmt.Println("========") - - for path, iniNew := range inisNew { - for _, sec := range iniNew.Sections() { - for _, key := range sec.Keys() { - str := sec.Key(key.Name()).String() - broken := strings.Contains(str, "\n") - broken = broken || strings.HasPrefix(str, "`") != strings.HasSuffix(str, "`") - broken = broken || strings.HasPrefix(str, "\"`") - broken = broken || strings.HasPrefix(str, "`\"") - broken = broken || strings.Count(str, `"`)%2 == 1 - broken = broken || strings.Count(str, "`")%2 == 1 - if broken && !brokenWarned.Contains(sec.Name()+"."+key.Name()) { - fmt.Printf("WARNING: found broken locale: %s , [%s] %s\n", path, sec.Name(), key.Name()) - fmt.Printf("\tstr: %s\n", strings.ReplaceAll(str, "\n", "\\n")) - fmt.Println("----") - } - } - } - } -} diff --git a/build/generate-emoji.go b/build/generate-emoji.go index 446ab5f4405..cbc1be2139e 100644 --- a/build/generate-emoji.go +++ b/build/generate-emoji.go @@ -24,8 +24,8 @@ import ( ) const ( - gemojiURL = "https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json" - maxUnicodeVersion = 15 + gemojiURL = "https://raw.githubusercontent.com/rhysd/gemoji/537ff2d7e0496e9964824f7f73ec7ece88c9765a/db/emoji.json" + maxUnicodeVersion = 16 ) var flagOut = flag.String("o", "modules/emoji/emoji_data.go", "out") @@ -149,8 +149,8 @@ func generate() ([]byte, error) { } // write a JSON file to use with tribute (write before adding skin tones since we can't support them there yet) - file, _ := json.Marshal(data) - _ = os.WriteFile("assets/emoji.json", file, 0o644) + file, _ := json.MarshalIndent(data, "", " ") + _ = os.WriteFile("assets/emoji.json", append(file, '\n'), 0o644) // Add skin tones to emoji that support it var ( diff --git a/build/generate-go-licenses.go b/build/generate-go-licenses.go index 84ba39025c4..b710fdb841f 100644 --- a/build/generate-go-licenses.go +++ b/build/generate-go-licenses.go @@ -8,99 +8,219 @@ package main import ( "encoding/json" "fmt" - "io/fs" "os" - "path" + "os/exec" "path/filepath" "regexp" + "slices" "sort" "strings" - - "code.gitea.io/gitea/modules/container" ) // regexp is based on go-license, excluding README and NOTICE // https://github.com/google/go-licenses/blob/master/licenses/find.go var licenseRe = regexp.MustCompile(`^(?i)((UN)?LICEN(S|C)E|COPYING).*$`) +// primaryLicenseRe matches exact primary license filenames without suffixes. +// When a directory has both primary and variant files (e.g. LICENSE and +// LICENSE.docs), only the primary files are kept. +var primaryLicenseRe = regexp.MustCompile(`^(?i)(LICEN[SC]E|COPYING)$`) + +// ignoredNames are LicenseEntry.Name values to exclude from the output. +var ignoredNames = map[string]bool{ + "code.gitea.io/gitea": true, + "code.gitea.io/gitea/options/license": true, +} + +var excludedExt = map[string]bool{ + ".gitignore": true, + ".go": true, + ".mod": true, + ".sum": true, + ".toml": true, + ".yaml": true, + ".yml": true, +} + +type ModuleInfo struct { + Path string + Dir string + PkgDirs []string // directories of packages imported from this module +} + type LicenseEntry struct { Name string `json:"name"` Path string `json:"path"` LicenseText string `json:"licenseText"` } -func main() { - if len(os.Args) != 3 { - fmt.Println("usage: go run generate-go-licenses.go ") +// getModules returns all dependency modules with their local directory paths +// and the package directories used from each module. +func getModules(goCmd string) []ModuleInfo { + cmd := exec.Command(goCmd, "list", "-deps", "-f", + "{{if .Module}}{{.Module.Path}}\t{{.Module.Dir}}\t{{.Dir}}{{end}}", "./...") + cmd.Stderr = os.Stderr + // Use GOOS=linux with CGO to ensure we capture all platform-specific + // dependencies, matching the CI environment. + cmd.Env = append(os.Environ(), "GOOS=linux", "GOARCH=amd64", "CGO_ENABLED=1") + output, err := cmd.Output() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to run 'go list -deps': %v\n", err) os.Exit(1) } - base, out := os.Args[1], os.Args[2] - - // Add ext for excluded files because license_test.go will be included for some reason. - // And there are more files that should be excluded, check with: - // - // go run github.com/google/go-licenses@v1.6.0 save . --force --save_path=.go-licenses 2>/dev/null - // find .go-licenses -type f | while read FILE; do echo "${$(basename $FILE)##*.}"; done | sort -u - // AUTHORS - // COPYING - // LICENSE - // Makefile - // NOTICE - // gitignore - // go - // md - // mod - // sum - // toml - // txt - // yml - // - // It could be removed once we have a better regex. - excludedExt := container.SetOf(".gitignore", ".go", ".mod", ".sum", ".toml", ".yml") - - var paths []string - err := filepath.WalkDir(base, func(path string, entry fs.DirEntry, err error) error { - if err != nil { - return err + var modules []ModuleInfo + seen := make(map[string]int) // module path -> index in modules + for _, line := range strings.Split(string(output), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue } - if entry.IsDir() || !licenseRe.MatchString(entry.Name()) || excludedExt.Contains(filepath.Ext(entry.Name())) { - return nil + parts := strings.Split(line, "\t") + if len(parts) != 3 { + continue } - paths = append(paths, path) - return nil - }) - if err != nil { - panic(err) + modPath, modDir, pkgDir := parts[0], parts[1], parts[2] + if idx, ok := seen[modPath]; ok { + modules[idx].PkgDirs = append(modules[idx].PkgDirs, pkgDir) + } else { + seen[modPath] = len(modules) + modules = append(modules, ModuleInfo{ + Path: modPath, + Dir: modDir, + PkgDirs: []string{pkgDir}, + }) + } + } + return modules +} + +// findLicenseFiles scans a module's root directory and its used package +// directories for license files. It also walks up from each package directory +// to the module root, scanning intermediate directories. Subdirectory licenses +// are only included if their text differs from the root license(s). +func findLicenseFiles(mod ModuleInfo) []LicenseEntry { + var entries []LicenseEntry + seenTexts := make(map[string]bool) + + // First, collect root-level license files. + entries = append(entries, scanDirForLicenses(mod.Dir, mod.Path, "")...) + for _, e := range entries { + seenTexts[e.LicenseText] = true } - sort.Strings(paths) + // Then check each package directory and all intermediate parent directories + // up to the module root for license files with unique text. + seenDirs := map[string]bool{mod.Dir: true} + for _, pkgDir := range mod.PkgDirs { + for dir := pkgDir; dir != mod.Dir && strings.HasPrefix(dir, mod.Dir); dir = filepath.Dir(dir) { + if seenDirs[dir] { + continue + } + seenDirs[dir] = true + for _, e := range scanDirForLicenses(dir, mod.Path, mod.Dir) { + if !seenTexts[e.LicenseText] { + seenTexts[e.LicenseText] = true + entries = append(entries, e) + } + } + } + } + return entries +} + +// scanDirForLicenses reads a single directory for license files and returns entries. +// If moduleRoot is non-empty, paths are made relative to it. +func scanDirForLicenses(dir, modulePath, moduleRoot string) []LicenseEntry { + dirEntries, err := os.ReadDir(dir) + if err != nil { + return nil + } var entries []LicenseEntry - for _, filePath := range paths { - licenseText, err := os.ReadFile(filePath) - if err != nil { - panic(err) + for _, entry := range dirEntries { + if entry.IsDir() { + continue } - - pkgPath := filepath.ToSlash(filePath) - pkgPath = strings.TrimPrefix(pkgPath, base+"/") - pkgName := path.Dir(pkgPath) - - // There might be a bug somewhere in go-licenses that sometimes interprets the - // root package as "." and sometimes as "code.gitea.io/gitea". Workaround by - // removing both of them for the sake of stable output. - if pkgName == "." || pkgName == "code.gitea.io/gitea" { + name := entry.Name() + if !licenseRe.MatchString(name) { + continue + } + if excludedExt[strings.ToLower(filepath.Ext(name))] { continue } + content, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + continue + } + + entryName := modulePath + entryPath := modulePath + "/" + name + if moduleRoot != "" { + rel, _ := filepath.Rel(moduleRoot, dir) + if rel != "." { + relSlash := filepath.ToSlash(rel) + entryName = modulePath + "/" + relSlash + entryPath = modulePath + "/" + relSlash + "/" + name + } + } + entries = append(entries, LicenseEntry{ - Name: pkgName, - Path: pkgPath, - LicenseText: string(licenseText), + Name: entryName, + Path: entryPath, + LicenseText: string(content), }) } + // When multiple license files exist, prefer primary files (e.g. LICENSE) + // over variants with suffixes (e.g. LICENSE.docs, LICENSE-2.0.txt). + // If no primary file exists, keep only the first variant. + if len(entries) > 1 { + var primary []LicenseEntry + for _, e := range entries { + fileName := e.Path[strings.LastIndex(e.Path, "/")+1:] + if primaryLicenseRe.MatchString(fileName) { + primary = append(primary, e) + } + } + if len(primary) > 0 { + return primary + } + return entries[:1] + } + + return entries +} + +func main() { + if len(os.Args) != 2 { + fmt.Println("usage: go run generate-go-licenses.go ") + os.Exit(1) + } + + out := os.Args[1] + + goCmd := "go" + if env := os.Getenv("GO"); env != "" { + goCmd = env + } + + modules := getModules(goCmd) + + var entries []LicenseEntry + for _, mod := range modules { + entries = append(entries, findLicenseFiles(mod)...) + } + + entries = slices.DeleteFunc(entries, func(e LicenseEntry) bool { + return ignoredNames[e.Name] + }) + + sort.Slice(entries, func(i, j int) bool { + return entries[i].Path < entries[j].Path + }) + jsonBytes, err := json.MarshalIndent(entries, "", " ") if err != nil { panic(err) diff --git a/build/update-locales.sh b/build/update-locales.sh index 6f9ee334be4..5316746f302 100755 --- a/build/update-locales.sh +++ b/build/update-locales.sh @@ -1,52 +1,22 @@ #!/bin/sh # this script runs in alpine image which only has `sh` shell - -set +e -if sed --version 2>/dev/null | grep -q GNU; then - SED_INPLACE="sed -i" -else - SED_INPLACE="sed -i ''" -fi -set -e - -if [ ! -f ./options/locale/locale_en-US.ini ]; then +if [ ! -f ./options/locale/locale_en-US.json ]; then echo "please run this script in the root directory of the project" exit 1 fi -mv ./options/locale/locale_en-US.ini ./options/ - -# the "ini" library for locale has many quirks, its behavior is different from Crowdin. -# see i18n_test.go for more details - -# this script helps to unquote the Crowdin outputs for the quirky ini library -# * find all `key="...\"..."` lines -# * remove the leading quote -# * remove the trailing quote -# * unescape the quotes -# * eg: key="...\"..." => key=..."... -$SED_INPLACE -r -e '/^[-.A-Za-z0-9_]+[ ]*=[ ]*".*"$/ { - s/^([-.A-Za-z0-9_]+)[ ]*=[ ]*"/\1=/ - s/"$// - s/\\"/"/g - }' ./options/locale/*.ini - -# * if the escaped line is incomplete like `key="...` or `key=..."`, quote it with backticks -# * eg: key="... => key=`"...` -# * eg: key=..." => key=`..."` -$SED_INPLACE -r -e 's/^([-.A-Za-z0-9_]+)[ ]*=[ ]*(".*[^"])$/\1=`\2`/' ./options/locale/*.ini -$SED_INPLACE -r -e 's/^([-.A-Za-z0-9_]+)[ ]*=[ ]*([^"].*")$/\1=`\2`/' ./options/locale/*.ini +mv ./options/locale/locale_en-US.json ./options/ # Remove translation under 25% of en_us -baselines=$(wc -l "./options/locale_en-US.ini" | cut -d" " -f1) +baselines=$(cat "./options/locale_en-US.json" | wc -l) baselines=$((baselines / 4)) -for filename in ./options/locale/*.ini; do - lines=$(wc -l "$filename" | cut -d" " -f1) - if [ $lines -lt $baselines ]; then +for filename in ./options/locale/*.json; do + lines=$(cat "$filename" | wc -l) + if [ "$lines" -lt "$baselines" ]; then echo "Removing $filename: $lines/$baselines" rm "$filename" fi done -mv ./options/locale_en-US.ini ./options/locale/ +mv ./options/locale_en-US.json ./options/locale/ diff --git a/cmd/admin.go b/cmd/admin.go index 5c58a40ca27..dbd48e57279 100644 --- a/cmd/admin.go +++ b/cmd/admin.go @@ -121,7 +121,7 @@ func runRepoSyncReleases(ctx context.Context, _ *cli.Command) error { } log.Trace("Processing next %d repos of %d", len(repos), count) for _, repo := range repos { - log.Trace("Synchronizing repo %s with path %s", repo.FullName(), repo.RepoPath()) + log.Trace("Synchronizing repo %s with path %s", repo.FullName(), repo.RelativePath()) gitRepo, err := gitrepo.OpenRepository(ctx, repo) if err != nil { log.Warn("OpenRepository: %v", err) @@ -134,7 +134,7 @@ func runRepoSyncReleases(ctx context.Context, _ *cli.Command) error { } log.Trace(" currentNumReleases is %d, running SyncReleasesWithTags", oldnum) - if err = repo_module.SyncReleasesWithTags(ctx, repo, gitRepo); err != nil { + if _, err = repo_module.SyncReleasesWithTags(ctx, repo, gitRepo); err != nil { log.Warn(" SyncReleasesWithTags: %v", err) gitRepo.Close() continue @@ -147,7 +147,7 @@ func runRepoSyncReleases(ctx context.Context, _ *cli.Command) error { continue } - log.Trace(" repo %s releases synchronized to tags: from %d to %d", + log.Trace("repo %s releases synchronized to tags: from %d to %d", repo.FullName(), oldnum, count) gitRepo.Close() } diff --git a/cmd/admin_auth_ldap.go b/cmd/admin_auth_ldap.go index 069ad6600c7..c9be5abb379 100644 --- a/cmd/admin_auth_ldap.go +++ b/cmd/admin_auth_ldap.go @@ -94,6 +94,10 @@ func commonLdapCLIFlags() []cli.Flag { Name: "public-ssh-key-attribute", Usage: "The attribute of the user’s LDAP record containing the user’s public ssh key.", }, + &cli.BoolFlag{ + Name: "ssh-keys-are-verified", + Usage: "Set to true to automatically flag SSH keys in LDAP as verified.", + }, &cli.BoolFlag{ Name: "skip-local-2fa", Usage: "Set to true to skip local 2fa for users authenticated by this source", @@ -294,6 +298,9 @@ func parseLdapConfig(c *cli.Command, config *ldap.Source) error { if c.IsSet("public-ssh-key-attribute") { config.AttributeSSHPublicKey = c.String("public-ssh-key-attribute") } + if c.IsSet("ssh-keys-are-verified") { + config.SSHKeysAreVerified = c.Bool("ssh-keys-are-verified") + } if c.IsSet("avatar-attribute") { config.AttributeAvatar = c.String("avatar-attribute") } diff --git a/cmd/admin_auth_ldap_test.go b/cmd/admin_auth_ldap_test.go index 2da7ebc573b..0e3e465e81e 100644 --- a/cmd/admin_auth_ldap_test.go +++ b/cmd/admin_auth_ldap_test.go @@ -233,7 +233,7 @@ func TestAddLdapBindDn(t *testing.T) { }, getAuthSourceByID: func(ctx context.Context, id int64) (*auth.Source, error) { assert.FailNow(t, "getAuthSourceByID called", "case %d: should not call getAuthSourceByID", n) - return nil, nil + return nil, nil //nolint:nilnil // mock function covering improper behavior }, } @@ -463,7 +463,7 @@ func TestAddLdapSimpleAuth(t *testing.T) { }, getAuthSourceByID: func(ctx context.Context, id int64) (*auth.Source, error) { assert.FailNow(t, "getAuthSourceById called", "case %d: should not call getAuthSourceByID", n) - return nil, nil + return nil, nil //nolint:nilnil // mock function covering improper behavior }, } diff --git a/cmd/admin_user_create.go b/cmd/admin_user_create.go index cbdb5f90e2e..7e5675cf58d 100644 --- a/cmd/admin_user_create.go +++ b/cmd/admin_user_create.go @@ -151,6 +151,7 @@ func runCreateUser(ctx context.Context, c *cli.Command) error { if err != nil { return err } + // codeql[disable-next-line=go/clear-text-logging] fmt.Printf("generated random password is '%s'\n", password) } else if userType == user_model.UserTypeIndividual { return errors.New("must set either password or random-password flag") diff --git a/cmd/admin_user_must_change_password.go b/cmd/admin_user_must_change_password.go index 8521853dc19..468d462b74c 100644 --- a/cmd/admin_user_must_change_password.go +++ b/cmd/admin_user_must_change_password.go @@ -58,6 +58,7 @@ func runMustChangePassword(ctx context.Context, c *cli.Command) error { return err } + // codeql[disable-next-line=go/clear-text-logging] fmt.Printf("Updated %d users setting MustChangePassword to %t\n", n, mustChangePassword) return nil } diff --git a/cmd/cmd.go b/cmd/cmd.go index 5b96bcbf9a9..25e90a16950 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -121,6 +121,12 @@ func globalBool(c *cli.Command, name string) bool { // Any log appears in git stdout pipe will break the git protocol, eg: client can't push and hangs forever. func PrepareConsoleLoggerLevel(defaultLevel log.Level) func(context.Context, *cli.Command) (context.Context, error) { return func(ctx context.Context, c *cli.Command) (context.Context, error) { + if setting.InstallLock { + // During config loading, there might also be logs (for example: deprecation warnings). + // It must make sure that console logger is set up before config is loaded. + log.Error("Config is loaded before console logger is setup, it will cause bugs. Please fix it.") + return nil, errors.New("console logger must be setup before config is loaded") + } level := defaultLevel if globalBool(c, "quiet") { level = log.FATAL diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 00000000000..5303b0e1e07 --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,156 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "errors" + "fmt" + "os" + + "code.gitea.io/gitea/modules/setting" + + "github.com/urfave/cli/v3" +) + +func cmdConfig() *cli.Command { + subcmdConfigEditIni := &cli.Command{ + Name: "edit-ini", + Usage: "Load an existing INI file, apply environment variables, keep specified keys, and output to a new INI file.", + Description: ` +Help users to edit the Gitea configuration INI file. + +# Keep Specified Keys + +If you need to re-create the configuration file with only a subset of keys, +you can provide an INI template file for the kept keys and use the "--config-keep-keys" flag. +For example, if a helm chart needs to reset the settings and only keep SECRET_KEY, +it can use a template file (only keys take effect, values are ignored): + + [security] + SECRET_KEY= + +$ ./gitea config edit-ini --config app-old.ini --config-keep-keys app-keys.ini --out app-new.ini + +# Map Environment Variables to INI Configuration + +Environment variables of the form "GITEA__section_name__KEY_NAME" +will be mapped to the ini section "[section_name]" and the key +"KEY_NAME" with the value as provided. + +Environment variables of the form "GITEA__section_name__KEY_NAME__FILE" +will be mapped to the ini section "[section_name]" and the key +"KEY_NAME" with the value loaded from the specified file. + +Environment variable keys can only contain characters "0-9A-Z_", +if a section or key name contains dot ".", it needs to be escaped as _0x2E_. +For example, to apply this config: + + [git.config] + foo.bar=val + +$ export GITEA__git_0x2E_config__foo_0x2E_bar=val + +# Put All Together + +$ ./gitea config edit-ini --config app.ini --config-keep-keys app-keys.ini --apply-env {--in-place|--out app-new.ini} +`, + Flags: []cli.Flag{ + // "--config" flag is provided by global flags, and this flag is also used by "environment-to-ini" script wrapper + // "--in-place" is also used by "environment-to-ini" script wrapper for its old behavior: always overwrite the existing config file + &cli.BoolFlag{ + Name: "in-place", + Usage: "Output to the same config file as input. This flag will be ignored if --out is set.", + }, + &cli.StringFlag{ + Name: "config-keep-keys", + Usage: "An INI template file containing keys for keeping. Only the keys defined in the INI template will be kept from old config. If not set, all keys will be kept.", + }, + &cli.BoolFlag{ + Name: "apply-env", + Usage: "Apply all GITEA__* variables from the environment to the config.", + }, + &cli.StringFlag{ + Name: "out", + Usage: "Destination config file to write to.", + }, + }, + Action: runConfigEditIni, + } + + return &cli.Command{ + Name: "config", + Usage: "Manage Gitea configuration", + Commands: []*cli.Command{ + subcmdConfigEditIni, + }, + } +} + +func runConfigEditIni(_ context.Context, c *cli.Command) error { + // the config system may change the environment variables, so get a copy first, to be used later + env := append([]string{}, os.Environ()...) + + // don't use the guessed setting.CustomConf, instead, require the user to provide --config explicitly + if !c.IsSet("config") { + return errors.New("flag is required but not set: --config") + } + configFileIn := c.String("config") + + cfgIn, err := setting.NewConfigProviderFromFile(configFileIn) + if err != nil { + return fmt.Errorf("failed to load config file %q: %v", configFileIn, err) + } + + // determine output config file: use "--out" flag or use "--in-place" flag to overwrite input file + inPlace := c.Bool("in-place") + configFileOut := c.String("out") + if configFileOut == "" { + if !inPlace { + return errors.New("either --in-place or --out must be specified") + } + configFileOut = configFileIn // in-place edit + } + + needWriteOut := configFileOut != configFileIn + + cfgOut := cfgIn + configKeepKeys := c.String("config-keep-keys") + if configKeepKeys != "" { + needWriteOut = true + cfgOut, err = setting.NewConfigProviderFromFile(configKeepKeys) + if err != nil { + return fmt.Errorf("failed to load config-keep-keys template file %q: %v", configKeepKeys, err) + } + + for _, secOut := range cfgOut.Sections() { + for _, keyOut := range secOut.Keys() { + secIn := cfgIn.Section(secOut.Name()) + keyIn := setting.ConfigSectionKey(secIn, keyOut.Name()) + if keyIn != nil { + keyOut.SetValue(keyIn.String()) + } else { + secOut.DeleteKey(keyOut.Name()) + } + } + if len(secOut.Keys()) == 0 { + cfgOut.DeleteSection(secOut.Name()) + } + } + } + + if c.Bool("apply-env") { + if setting.EnvironmentToConfig(cfgOut, env) { + needWriteOut = true + } + } + + if needWriteOut { + err = cfgOut.SaveTo(configFileOut) + if err != nil { + return err + } + } + return nil +} diff --git a/cmd/config_test.go b/cmd/config_test.go new file mode 100644 index 00000000000..d123daa6173 --- /dev/null +++ b/cmd/config_test.go @@ -0,0 +1,85 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestConfigEdit(t *testing.T) { + tmpDir := t.TempDir() + configOld := tmpDir + "/app-old.ini" + configTemplate := tmpDir + "/app-template.ini" + _ = os.WriteFile(configOld, []byte(` +[sec] +k1=v1 +k2=v2 +`), os.ModePerm) + + _ = os.WriteFile(configTemplate, []byte(` +[sec] +k1=in-template + +[sec2] +k3=v3 +`), os.ModePerm) + + t.Setenv("GITEA__EnV__KeY", "val") + + t.Run("OutputToNewWithEnv", func(t *testing.T) { + configNew := tmpDir + "/app-new.ini" + err := NewMainApp(AppVersion{}).Run(t.Context(), []string{ + "./gitea", "--config", configOld, + "config", "edit-ini", + "--apply-env", + "--config-keep-keys", configTemplate, + "--out", configNew, + }) + require.NoError(t, err) + + // "k1" old value is kept because its key is in the template + // "k2" is removed because it isn't in the template + // "k3" isn't in new config because it isn't in the old config + // [env] is applied from environment variable + data, _ := os.ReadFile(configNew) + require.Equal(t, `[sec] +k1 = v1 + +[env] +KeY = val +`, string(data)) + }) + + t.Run("OutputToExisting(environment-to-ini)", func(t *testing.T) { + // the legacy "environment-to-ini" (now a wrapper script) behavior: + // if no "--out", then "--in-place" must be used to overwrite the existing "--config" file + err := NewMainApp(AppVersion{}).Run(t.Context(), []string{ + "./gitea", "config", "edit-ini", + "--apply-env", + "--config", configOld, + }) + require.ErrorContains(t, err, "either --in-place or --out must be specified") + + // simulate the "environment-to-ini" behavior with "--in-place" + err = NewMainApp(AppVersion{}).Run(t.Context(), []string{ + "./gitea", "config", "edit-ini", + "--in-place", + "--apply-env", + "--config", configOld, + }) + require.NoError(t, err) + + data, _ := os.ReadFile(configOld) + require.Equal(t, `[sec] +k1 = v1 +k2 = v2 + +[env] +KeY = val +`, string(data)) + }) +} diff --git a/cmd/generate.go b/cmd/generate.go index cf491604efa..9cb4cf39171 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -91,6 +91,7 @@ func runGenerateSecretKey(_ context.Context, c *cli.Command) error { return err } + // codeql[disable-next-line=go/clear-text-logging] fmt.Printf("%s", secretKey) if isatty.IsTerminal(os.Stdout.Fd()) { diff --git a/cmd/hook.go b/cmd/hook.go index 2f866dd396b..4f6492b0f05 100644 --- a/cmd/hook.go +++ b/cmd/hook.go @@ -163,6 +163,14 @@ func (n *nilWriter) WriteString(s string) (int, error) { return len(s), nil } +func parseGitHookCommitRefLine(line string) (oldCommitID, newCommitID string, refFullName git.RefName, ok bool) { + fields := strings.Split(line, " ") + if len(fields) != 3 { + return "", "", "", false + } + return fields[0], fields[1], git.RefName(fields[2]), true +} + func runHookPreReceive(ctx context.Context, c *cli.Command) error { if isInternal, _ := strconv.ParseBool(os.Getenv(repo_module.EnvIsInternal)); isInternal { return nil @@ -186,7 +194,7 @@ Gitea or set your environment appropriately.`, "") userID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64) prID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPRID), 10, 64) deployKeyID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvDeployKeyID), 10, 64) - actionPerm, _ := strconv.ParseInt(os.Getenv(repo_module.EnvActionPerm), 10, 64) + actionPerm, _ := strconv.Atoi(os.Getenv(repo_module.EnvActionPerm)) hookOptions := private.HookOptions{ UserID: userID, @@ -196,7 +204,8 @@ Gitea or set your environment appropriately.`, "") GitPushOptions: pushOptions(), PullRequestID: prID, DeployKeyID: deployKeyID, - ActionPerm: int(actionPerm), + ActionPerm: actionPerm, + IsWiki: isWiki, } scanner := bufio.NewScanner(os.Stdin) @@ -228,14 +237,11 @@ Gitea or set your environment appropriately.`, "") continue } - fields := bytes.Fields(scanner.Bytes()) - if len(fields) != 3 { + oldCommitID, newCommitID, refFullName, ok := parseGitHookCommitRefLine(scanner.Text()) + if !ok { continue } - oldCommitID := string(fields[0]) - newCommitID := string(fields[1]) - refFullName := git.RefName(fields[2]) total++ lastline++ @@ -313,7 +319,7 @@ func runHookPostReceive(ctx context.Context, c *cli.Command) error { setup(ctx, c.Bool("debug")) // First of all run update-server-info no matter what - if _, _, err := gitcmd.NewCommand("update-server-info").RunStdString(ctx, nil); err != nil { + if err := gitcmd.NewCommand("update-server-info").RunWithStderr(ctx); err != nil { return fmt.Errorf("failed to call 'git update-server-info': %w", err) } @@ -361,6 +367,7 @@ Gitea or set your environment appropriately.`, "") GitPushOptions: pushOptions(), PullRequestID: prID, PushTrigger: repo_module.PushTrigger(os.Getenv(repo_module.EnvPushTrigger)), + IsWiki: isWiki, } oldCommitIDs := make([]string, hookBatchSize) newCommitIDs := make([]string, hookBatchSize) @@ -378,16 +385,13 @@ Gitea or set your environment appropriately.`, "") continue } - fields := bytes.Fields(scanner.Bytes()) - if len(fields) != 3 { + var ok bool + oldCommitIDs[count], newCommitIDs[count], refFullNames[count], ok = parseGitHookCommitRefLine(scanner.Text()) + if !ok { continue } fmt.Fprintf(out, ".") - oldCommitIDs[count] = string(fields[0]) - newCommitIDs[count] = string(fields[1]) - refFullNames[count] = git.RefName(fields[2]) - commitID, _ := git.NewIDFromString(newCommitIDs[count]) if refFullNames[count] == git.BranchPrefix+"master" && !commitID.IsZero() && count == total { masterPushed = true @@ -511,6 +515,7 @@ Gitea or set your environment appropriately.`, "") reader := bufio.NewReader(os.Stdin) repoUser := os.Getenv(repo_module.EnvRepoUsername) + isWiki, _ := strconv.ParseBool(os.Getenv(repo_module.EnvRepoIsWiki)) repoName := os.Getenv(repo_module.EnvRepoName) pusherID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64) pusherName := os.Getenv(repo_module.EnvPusherName) @@ -588,14 +593,15 @@ Gitea or set your environment appropriately.`, "") UserName: pusherName, UserID: pusherID, GitPushOptions: make(map[string]string), + IsWiki: isWiki, } hookOptions.OldCommitIDs = make([]string, 0, hookBatchSize) hookOptions.NewCommitIDs = make([]string, 0, hookBatchSize) hookOptions.RefFullNames = make([]git.RefName, 0, hookBatchSize) for { - // note: pktLineTypeUnknow means pktLineTypeFlush and pktLineTypeData all allowed - rs, err = readPktLine(ctx, reader, pktLineTypeUnknow) + // note: pktLineTypeUnknown means pktLineTypeFlush and pktLineTypeData all allowed + rs, err = readPktLine(ctx, reader, pktLineTypeUnknown) if err != nil { return err } @@ -614,7 +620,7 @@ Gitea or set your environment appropriately.`, "") if hasPushOptions { for { - rs, err = readPktLine(ctx, reader, pktLineTypeUnknow) + rs, err = readPktLine(ctx, reader, pktLineTypeUnknown) if err != nil { return err } @@ -711,8 +717,8 @@ Gitea or set your environment appropriately.`, "") type pktLineType int64 const ( - // UnKnow type - pktLineTypeUnknow pktLineType = 0 + // Unknown type + pktLineTypeUnknown pktLineType = 0 // flush-pkt "0000" pktLineTypeFlush pktLineType = iota // data line diff --git a/cmd/hook_test.go b/cmd/hook_test.go index 86cd4834f20..fefc33c01c9 100644 --- a/cmd/hook_test.go +++ b/cmd/hook_test.go @@ -39,3 +39,17 @@ func TestPktLine(t *testing.T) { assert.NoError(t, err) assert.Equal(t, []byte("0007a\nb"), w.Bytes()) } + +func TestParseGitHookCommitRefLine(t *testing.T) { + oldCommitID, newCommitID, refName, ok := parseGitHookCommitRefLine("a b c") + assert.True(t, ok) + assert.Equal(t, "a", oldCommitID) + assert.Equal(t, "b", newCommitID) + assert.Equal(t, "c", string(refName)) + + _, _, _, ok = parseGitHookCommitRefLine("a\tb\tc") + assert.False(t, ok) + + _, _, _, ok = parseGitHookCommitRefLine("a b") + assert.False(t, ok) +} diff --git a/cmd/keys.go b/cmd/keys.go index 5ca3b91e15e..035d39bfb86 100644 --- a/cmd/keys.go +++ b/cmd/keys.go @@ -19,7 +19,7 @@ import ( var CmdKeys = &cli.Command{ Name: "keys", Usage: "(internal) Should only be called by SSH server", - Hidden: true, // internal commands shouldn't not be visible + Hidden: true, // internal commands shouldn't be visible Description: "Queries the Gitea database to get the authorized command for a given ssh key fingerprint", Before: PrepareConsoleLoggerLevel(log.FATAL), Action: runKeys, diff --git a/cmd/mailer.go b/cmd/mailer.go index 72bd8e56012..a7dbd68e20a 100644 --- a/cmd/mailer.go +++ b/cmd/mailer.go @@ -17,10 +17,10 @@ func runSendMail(ctx context.Context, c *cli.Command) error { setting.MustInstalled() subject := c.String("title") - confirmSkiped := c.Bool("force") + confirmSkipped := c.Bool("force") body := c.String("content") - if !confirmSkiped { + if !confirmSkipped { if len(body) == 0 { fmt.Print("warning: Content is empty") } diff --git a/cmd/main.go b/cmd/main.go index 3fdaf48ed96..2ee00382d75 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -50,11 +50,15 @@ DEFAULT CONFIGURATION: func prepareSubcommandWithGlobalFlags(originCmd *cli.Command) { originBefore := originCmd.Before - originCmd.Before = func(ctx context.Context, cmd *cli.Command) (context.Context, error) { - prepareWorkPathAndCustomConf(cmd) + originCmd.Before = func(ctxOrig context.Context, cmd *cli.Command) (ctx context.Context, err error) { + ctx = ctxOrig if originBefore != nil { - return originBefore(ctx, cmd) + ctx, err = originBefore(ctx, cmd) + if err != nil { + return ctx, err + } } + prepareWorkPathAndCustomConf(cmd) return ctx, nil } } @@ -128,6 +132,7 @@ func NewMainApp(appVer AppVersion) *cli.Command { // these sub-commands do not need the config file, and they do not depend on any path or environment variable. subCmdStandalone := []*cli.Command{ + cmdConfig(), cmdCert(), CmdGenerate, CmdDocs, @@ -144,7 +149,7 @@ func NewMainApp(appVer AppVersion) *cli.Command { app.Commands = append(app.Commands, subCmdWithConfig...) app.Commands = append(app.Commands, subCmdStandalone...) - setting.InitGiteaEnvVars() + setting.UnsetUnnecessaryEnvVars() return app } diff --git a/cmd/main_test.go b/cmd/main_test.go index d49ebfd4df4..b1f6bb3ba9b 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -15,6 +15,7 @@ import ( "code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/modules/util" "github.com/stretchr/testify/assert" "github.com/urfave/cli/v3" @@ -28,11 +29,11 @@ func makePathOutput(workPath, customPath, customConf string) string { return fmt.Sprintf("WorkPath=%s\nCustomPath=%s\nCustomConf=%s", workPath, customPath, customConf) } -func newTestApp(testCmdAction cli.ActionFunc) *cli.Command { +func newTestApp(testCmd cli.Command) *cli.Command { app := NewMainApp(AppVersion{}) - testCmd := &cli.Command{Name: "test-cmd", Action: testCmdAction} - prepareSubcommandWithGlobalFlags(testCmd) - app.Commands = append(app.Commands, testCmd) + testCmd.Name = util.IfZero(testCmd.Name, "test-cmd") + prepareSubcommandWithGlobalFlags(&testCmd) + app.Commands = append(app.Commands, &testCmd) app.DefaultCommand = testCmd.Name return app } @@ -156,9 +157,12 @@ func TestCliCmd(t *testing.T) { for _, c := range cases { t.Run(c.cmd, func(t *testing.T) { - app := newTestApp(func(ctx context.Context, cmd *cli.Command) error { - _, _ = fmt.Fprint(cmd.Root().Writer, makePathOutput(setting.AppWorkPath, setting.CustomPath, setting.CustomConf)) - return nil + defer test.MockVariableValue(&setting.InstallLock, false)() + app := newTestApp(cli.Command{ + Action: func(ctx context.Context, cmd *cli.Command) error { + _, _ = fmt.Fprint(cmd.Root().Writer, makePathOutput(setting.AppWorkPath, setting.CustomPath, setting.CustomConf)) + return nil + }, }) for k, v := range c.env { t.Setenv(k, v) @@ -167,37 +171,63 @@ func TestCliCmd(t *testing.T) { r, err := runTestApp(app, args...) assert.NoError(t, err, c.cmd) assert.NotEmpty(t, c.exp, c.cmd) - assert.Contains(t, r.Stdout, c.exp, c.cmd) + if !assert.Contains(t, r.Stdout, c.exp, c.cmd) { + t.Log("Full output:\n" + r.Stdout) + t.Log("Expected:\n" + c.exp) + } }) } } func TestCliCmdError(t *testing.T) { - app := newTestApp(func(ctx context.Context, cmd *cli.Command) error { return errors.New("normal error") }) + app := newTestApp(cli.Command{Action: func(ctx context.Context, cmd *cli.Command) error { return errors.New("normal error") }}) r, err := runTestApp(app, "./gitea", "test-cmd") assert.Error(t, err) assert.Equal(t, 1, r.ExitCode) assert.Empty(t, r.Stdout) assert.Equal(t, "Command error: normal error\n", r.Stderr) - app = newTestApp(func(ctx context.Context, cmd *cli.Command) error { return cli.Exit("exit error", 2) }) + app = newTestApp(cli.Command{Action: func(ctx context.Context, cmd *cli.Command) error { return cli.Exit("exit error", 2) }}) r, err = runTestApp(app, "./gitea", "test-cmd") assert.Error(t, err) assert.Equal(t, 2, r.ExitCode) assert.Empty(t, r.Stdout) assert.Equal(t, "exit error\n", r.Stderr) - app = newTestApp(func(ctx context.Context, cmd *cli.Command) error { return nil }) + app = newTestApp(cli.Command{Action: func(ctx context.Context, cmd *cli.Command) error { return nil }}) r, err = runTestApp(app, "./gitea", "test-cmd", "--no-such") assert.Error(t, err) assert.Equal(t, 1, r.ExitCode) assert.Empty(t, r.Stdout) assert.Equal(t, "Incorrect Usage: flag provided but not defined: -no-such\n\n", r.Stderr) - app = newTestApp(func(ctx context.Context, cmd *cli.Command) error { return nil }) + app = newTestApp(cli.Command{Action: func(ctx context.Context, cmd *cli.Command) error { return nil }}) r, err = runTestApp(app, "./gitea", "test-cmd") assert.NoError(t, err) assert.Equal(t, -1, r.ExitCode) // the cli.OsExiter is not called assert.Empty(t, r.Stdout) assert.Empty(t, r.Stderr) } + +func TestCliCmdBefore(t *testing.T) { + ctxNew := context.WithValue(context.Background(), any("key"), "value") + configValues := map[string]string{} + setting.CustomConf = "/tmp/any.ini" + var actionCtx context.Context + app := newTestApp(cli.Command{ + Before: func(context.Context, *cli.Command) (context.Context, error) { + configValues["before"] = setting.CustomConf + return ctxNew, nil + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + configValues["action"] = setting.CustomConf + actionCtx = ctx + return nil + }, + }) + _, err := runTestApp(app, "./gitea", "--config", "/dev/null", "test-cmd") + assert.NoError(t, err) + assert.Equal(t, ctxNew, actionCtx) + assert.Equal(t, "/tmp/any.ini", configValues["before"], "BeforeFunc must be called before preparing config") + assert.Equal(t, "/dev/null", configValues["action"]) +} diff --git a/cmd/migrate_storage.go b/cmd/migrate_storage.go index 2c63e15f509..a6bf9fa4b54 100644 --- a/cmd/migrate_storage.go +++ b/cmd/migrate_storage.go @@ -36,7 +36,7 @@ var CmdMigrateStorage = &cli.Command{ Name: "type", Aliases: []string{"t"}, Value: "", - Usage: "Type of stored files to copy. Allowed types: 'attachments', 'lfs', 'avatars', 'repo-avatars', 'repo-archivers', 'packages', 'actions-log', 'actions-artifacts", + Usage: "Type of stored files to copy. Allowed types: 'attachments', 'lfs', 'avatars', 'repo-avatars', 'repo-archivers', 'packages', 'actions-log', 'actions-artifacts'", }, &cli.StringFlag{ Name: "storage", diff --git a/cmd/serv.go b/cmd/serv.go index 76d8c81544e..4110fda0d50 100644 --- a/cmd/serv.go +++ b/cmd/serv.go @@ -13,13 +13,12 @@ import ( "path/filepath" "strconv" "strings" - "time" "unicode" asymkey_model "code.gitea.io/gitea/models/asymkey" git_model "code.gitea.io/gitea/models/git" "code.gitea.io/gitea/models/perm" - "code.gitea.io/gitea/models/repo" + repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git/gitcmd" "code.gitea.io/gitea/modules/json" @@ -32,7 +31,6 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/services/lfs" - "github.com/golang-jwt/jwt/v5" "github.com/kballard/go-shellquote" "github.com/urfave/cli/v3" ) @@ -133,27 +131,6 @@ func getAccessMode(verb, lfsVerb string) perm.AccessMode { return perm.AccessModeNone } -func getLFSAuthToken(ctx context.Context, lfsVerb string, results *private.ServCommandResults) (string, error) { - now := time.Now() - claims := lfs.Claims{ - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(now.Add(setting.LFS.HTTPAuthExpiry)), - NotBefore: jwt.NewNumericDate(now), - }, - RepoID: results.RepoID, - Op: lfsVerb, - UserID: results.UserID, - } - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - - // Sign and get the complete encoded token as a string using the secret - tokenString, err := token.SignedString(setting.LFS.JWTSecretBytes) - if err != nil { - return "", fail(ctx, "Failed to sign JWT Token", "Failed to sign JWT token: %v", err) - } - return "Bearer " + tokenString, nil -} - func runServ(ctx context.Context, c *cli.Command) error { // FIXME: This needs to internationalised setup(ctx, c.Bool("debug")) @@ -230,7 +207,7 @@ func runServ(ctx context.Context, c *cli.Command) error { username := repoPathFields[0] reponame := strings.TrimSuffix(repoPathFields[1], ".git") // “the-repo-name" or "the-repo-name.wiki" - if !repo.IsValidSSHAccessRepoName(reponame) { + if !repo_model.IsValidSSHAccessRepoName(reponame) { return fail(ctx, "Invalid repo name", "Invalid repo name: %s", reponame) } @@ -276,14 +253,16 @@ func runServ(ctx context.Context, c *cli.Command) error { return fail(ctx, extra.UserMsg, "ServCommand failed: %s", extra.Error) } - // LowerCase and trim the repoPath as that's how they are stored. - // This should be done after splitting the repoPath into username and reponame - // so that username and reponame are not affected. - repoPath = strings.ToLower(results.OwnerName + "/" + results.RepoName + ".git") + // because the original repoPath maybe redirected, we need to use the returned actual repository information + if results.IsWiki { + repoPath = repo_model.RelativeWikiPath(results.OwnerName, results.RepoName) + } else { + repoPath = repo_model.RelativePath(results.OwnerName, results.RepoName) + } // LFS SSH protocol if verb == git.CmdVerbLfsTransfer { - token, err := getLFSAuthToken(ctx, lfsVerb, results) + token, err := lfs.GetLFSAuthTokenWithBearer(lfs.AuthTokenOptions{Op: lfsVerb, UserID: results.UserID, RepoID: results.RepoID}) if err != nil { return err } @@ -294,7 +273,7 @@ func runServ(ctx context.Context, c *cli.Command) error { if verb == git.CmdVerbLfsAuthenticate { url := fmt.Sprintf("%s%s/%s.git/info/lfs", setting.AppURL, url.PathEscape(results.OwnerName), url.PathEscape(results.RepoName)) - token, err := getLFSAuthToken(ctx, lfsVerb, results) + token, err := lfs.GetLFSAuthTokenWithBearer(lfs.AuthTokenOptions{Op: lfsVerb, UserID: results.UserID, RepoID: results.RepoID}) if err != nil { return err } diff --git a/cmd/web.go b/cmd/web.go index 4723ddbbdd2..5000e780c56 100644 --- a/cmd/web.go +++ b/cmd/web.go @@ -8,14 +8,13 @@ import ( "fmt" "net" "net/http" + "net/http/pprof" "os" "path/filepath" "strconv" "strings" "time" - _ "net/http/pprof" // Used for debugging if enabled and a web server is running - "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/gtprof" @@ -23,6 +22,7 @@ import ( "code.gitea.io/gitea/modules/process" "code.gitea.io/gitea/modules/public" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers" "code.gitea.io/gitea/routers/install" @@ -156,7 +156,6 @@ func serveInstall(cmd *cli.Command) error { case <-graceful.GetManager().IsShutdown(): <-graceful.GetManager().Done() log.Info("PID: %d Gitea Web Finished", os.Getpid()) - log.GetManager().Close() return err default: } @@ -164,8 +163,6 @@ func serveInstall(cmd *cli.Command) error { } func serveInstalled(c *cli.Command) error { - setting.InitCfgProvider(setting.CustomConf) - setting.LoadCommonSettings() setting.MustInstalled() showWebStartupMessage("Prepare to run web server") @@ -231,27 +228,26 @@ func serveInstalled(c *cli.Command) error { err := listen(webRoutes, true) <-graceful.GetManager().Done() log.Info("PID: %d Gitea Web Finished", os.Getpid()) - log.GetManager().Close() return err } func servePprof() { - // FIXME: it shouldn't use the global DefaultServeMux, and it should use a proper context - http.DefaultServeMux.Handle("/debug/fgprof", fgprof.Handler()) + mux := http.NewServeMux() + mux.HandleFunc("/debug/pprof/", pprof.Index) + mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) + mux.HandleFunc("/debug/pprof/profile", pprof.Profile) + mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol) + mux.HandleFunc("/debug/pprof/trace", pprof.Trace) + mux.Handle("/debug/fgprof", fgprof.Handler()) + // FIXME: it should use a proper context _, _, finished := process.GetManager().AddTypedContext(context.TODO(), "Web: PProf Server", process.SystemProcessType, true) // The pprof server is for debug purpose only, it shouldn't be exposed on public network. At the moment, it's not worth introducing a configurable option for it. log.Info("Starting pprof server on localhost:6060") - log.Info("Stopped pprof server: %v", http.ListenAndServe("localhost:6060", nil)) + log.Info("Stopped pprof server: %v", http.ListenAndServe("localhost:6060", mux)) finished() } func runWeb(ctx context.Context, cmd *cli.Command) error { - defer func() { - if panicked := recover(); panicked != nil { - log.Fatal("PANIC: %v\n%s", panicked, log.Stack(2)) - } - }() - if subCmdName, valid := isValidDefaultSubCommand(cmd); !valid { return fmt.Errorf("unknown command: %s", subCmdName) } @@ -271,6 +267,10 @@ func runWeb(ctx context.Context, cmd *cli.Command) error { createPIDFile(cmd.String("pid")) } + // init the HTML renderer and load templates, if error happens, it will report the error immediately and exit with error log + // in dev mode, it won't exit, but watch the template files for changes + _ = templates.PageRenderer() + if !setting.InstallLock { if err := serveInstall(cmd); err != nil { return err diff --git a/cmd/web_acme.go b/cmd/web_acme.go index 5f7a3083349..a2e14638f8c 100644 --- a/cmd/web_acme.go +++ b/cmd/web_acme.go @@ -7,6 +7,7 @@ import ( "crypto/x509" "encoding/pem" "fmt" + "net" "net/http" "os" "strconv" @@ -124,8 +125,8 @@ func runACME(listenAddr string, m http.Handler) error { defer finished() log.Info("Running Let's Encrypt handler on %s", setting.HTTPAddr+":"+setting.PortToRedirect) - // all traffic coming into HTTP will be redirect to HTTPS automatically (LE HTTP-01 validation happens here) - err := runHTTP("tcp", setting.HTTPAddr+":"+setting.PortToRedirect, "Let's Encrypt HTTP Challenge", myACME.HTTPChallengeHandler(http.HandlerFunc(runLetsEncryptFallbackHandler)), setting.RedirectorUseProxyProtocol) + // all traffic coming into HTTP will be redirected to HTTPS automatically (LE HTTP-01 validation happens here) + err := runHTTP("tcp", net.JoinHostPort(setting.HTTPAddr, setting.PortToRedirect), "Let's Encrypt HTTP Challenge", myACME.HTTPChallengeHandler(http.HandlerFunc(runLetsEncryptFallbackHandler)), setting.RedirectorUseProxyProtocol) if err != nil { log.Fatal("Failed to start the Let's Encrypt handler on port %s: %v", setting.PortToRedirect, err) } diff --git a/contrib/environment-to-ini/README b/contrib/environment-to-ini/README deleted file mode 100644 index f1d3f2ae832..00000000000 --- a/contrib/environment-to-ini/README +++ /dev/null @@ -1,47 +0,0 @@ -Environment To Ini -================== - -Multiple docker users have requested that the Gitea docker is changed -to permit arbitrary configuration via environment variables. - -Gitea needs to use an ini file for configuration because the running -environment that starts the docker may not be the same as that used -by the hooks. An ini file also gives a good default and means that -users do not have to completely provide a full environment. - -With those caveats above, this command provides a generic way of -converting suitably structured environment variables into any ini -value. - -To use the command is very simple just run it and the default gitea -app.ini will be rewritten to take account of the variables provided, -however there are various options to give slightly different -behavior and these can be interrogated with the `-h` option. - -The environment variables should be of the form: - - GITEA__SECTION_NAME__KEY_NAME - -Note, SECTION_NAME in the notation above is case-insensitive. - -Environment variables are usually restricted to a reduced character -set "0-9A-Z_" - in order to allow the setting of sections with -characters outside of that set, they should be escaped as following: -"_0X2E_" for "." and "_0X2D_" for "-". The entire section and key names -can be escaped as a UTF8 byte string if necessary. E.g. to configure: - - """ - ... - [log.console] - COLORIZE=false - STDERR=true - ... - """ - -You would set the environment variables: "GITEA__LOG_0x2E_CONSOLE__COLORIZE=false" -and "GITEA__LOG_0x2E_CONSOLE__STDERR=false". Other examples can be found -on the configuration cheat sheet. - -To build locally, run: - - go build contrib/environment-to-ini/environment-to-ini.go diff --git a/contrib/environment-to-ini/environment-to-ini.go b/contrib/environment-to-ini/environment-to-ini.go deleted file mode 100644 index 5eb576c6fea..00000000000 --- a/contrib/environment-to-ini/environment-to-ini.go +++ /dev/null @@ -1,112 +0,0 @@ -// Copyright 2019 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package main - -import ( - "context" - "os" - - "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/setting" - - "github.com/urfave/cli/v3" -) - -func main() { - app := cli.Command{} - app.Name = "environment-to-ini" - app.Usage = "Use provided environment to update configuration ini" - app.Description = `As a helper to allow docker users to update the gitea configuration - through the environment, this command allows environment variables to - be mapped to values in the ini. - - Environment variables of the form "GITEA__SECTION_NAME__KEY_NAME" - will be mapped to the ini section "[section_name]" and the key - "KEY_NAME" with the value as provided. - - Environment variables of the form "GITEA__SECTION_NAME__KEY_NAME__FILE" - will be mapped to the ini section "[section_name]" and the key - "KEY_NAME" with the value loaded from the specified file. - - Environment variables are usually restricted to a reduced character - set "0-9A-Z_" - in order to allow the setting of sections with - characters outside of that set, they should be escaped as following: - "_0X2E_" for ".". The entire section and key names can be escaped as - a UTF8 byte string if necessary. E.g. to configure: - - """ - ... - [log.console] - COLORIZE=false - STDERR=true - ... - """ - - You would set the environment variables: "GITEA__LOG_0x2E_CONSOLE__COLORIZE=false" - and "GITEA__LOG_0x2E_CONSOLE__STDERR=false". Other examples can be found - on the configuration cheat sheet.` - app.Flags = []cli.Flag{ - &cli.StringFlag{ - Name: "custom-path", - Aliases: []string{"C"}, - Value: setting.CustomPath, - Usage: "Custom path file path", - }, - &cli.StringFlag{ - Name: "config", - Aliases: []string{"c"}, - Value: setting.CustomConf, - Usage: "Custom configuration file path", - }, - &cli.StringFlag{ - Name: "work-path", - Aliases: []string{"w"}, - Value: setting.AppWorkPath, - Usage: "Set the gitea working path", - }, - &cli.StringFlag{ - Name: "out", - Aliases: []string{"o"}, - Value: "", - Usage: "Destination file to write to", - }, - } - app.Action = runEnvironmentToIni - err := app.Run(context.Background(), os.Args) - if err != nil { - log.Fatal("Failed to run app with %s: %v", os.Args, err) - } -} - -func runEnvironmentToIni(_ context.Context, c *cli.Command) error { - // the config system may change the environment variables, so get a copy first, to be used later - env := append([]string{}, os.Environ()...) - setting.InitWorkPathAndCfgProvider(os.Getenv, setting.ArgWorkPathAndCustomConf{ - WorkPath: c.String("work-path"), - CustomPath: c.String("custom-path"), - CustomConf: c.String("config"), - }) - - cfg, err := setting.NewConfigProviderFromFile(setting.CustomConf) - if err != nil { - log.Fatal("Failed to load custom conf '%s': %v", setting.CustomConf, err) - } - - changed := setting.EnvironmentToConfig(cfg, env) - - // try to save the config file - destination := c.String("out") - if len(destination) == 0 { - destination = setting.CustomConf - } - if destination != setting.CustomConf || changed { - log.Info("Settings saved to: %q", destination) - err = cfg.SaveTo(destination) - if err != nil { - return err - } - } - - return nil -} diff --git a/contrib/upgrade.sh b/contrib/upgrade.sh index e5e296ea8b2..2593d245093 100755 --- a/contrib/upgrade.sh +++ b/contrib/upgrade.sh @@ -108,7 +108,9 @@ curl --connect-timeout 10 --silent --show-error --fail --location -O "$binurl{,. sha256sum -c "${binname}.xz.sha256" if [[ -z "${ignore_gpg:-}" ]]; then require gpg - gpg --keyserver keys.openpgp.org --recv 7C9E68152594688862D62AF62D9AE806EC1592E2 + # try to use curl first, it uses standard tcp 443 port and works better behind strict firewall rules + curl -fsSL --connect-timeout 10 "https://keys.openpgp.org/vks/v1/by-fingerprint/7C9E68152594688862D62AF62D9AE806EC1592E2" | gpg --import \ + || gpg --keyserver keys.openpgp.org --recv 7C9E68152594688862D62AF62D9AE806EC1592E2 gpg --verify "${binname}.xz.asc" "${binname}.xz" || { echo 'Signature does not match'; exit 1; } fi rm "${binname}".xz.{sha256,asc} @@ -127,6 +129,8 @@ echo "Creating backup in $giteahome" giteacmd dump $backupopts echo "Updating binary at $giteabin" cp -f "$giteabin" "$giteabin.bak" && mv -f "$binname" "$giteabin" +# Restore SELinux context if applicable (e.g. RHEL/Fedora) +command -v restorecon &>/dev/null && restorecon -v "$giteabin" || true $service_start $service_status diff --git a/crowdin.yml b/crowdin.yml index 35a38d768ca..d14d484cf81 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -4,9 +4,9 @@ base_path: "." base_url: "https://api.crowdin.com" preserve_hierarchy: true files: - - source: "/options/locale/locale_en-US.ini" - translation: "/options/locale/locale_%locale%.ini" - type: "ini" + - source: "/options/locale/locale_en-US.json" + translation: "/options/locale/locale_%locale%.json" + type: "json" skip_untranslated_strings: true export_only_approved: true update_option: "update_as_unapproved" diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index aa2fcee7655..9297f3d0627 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -74,6 +74,7 @@ RUN_USER = ; git ;; especially when the Gitea instance needs to be accessed in a container network. ;; * legacy: detect the public URL from "Host" header if "X-Forwarded-Proto" header exists, otherwise use "ROOT_URL". ;; * auto: always use "Host" header, and also use "X-Forwarded-Proto" header if it exists. If no "Host" header, use "ROOT_URL". +;; * never: always use "ROOT_URL", never detect from request headers. ;PUBLIC_URL_DETECTION = legacy ;; ;; For development purpose only. It makes Gitea handle sub-path ("/sub-path/owner/repo/...") directly when debugging without a reverse proxy. @@ -238,9 +239,6 @@ RUN_USER = ; git ;; Indicate whether to check minimum key size with corresponding type ;MINIMUM_KEY_SIZE_CHECK = false ;; -;; Disable CDN even in "prod" mode -;OFFLINE_MODE = true -;; ;; TLS Settings: Either ACME or manual ;; (Other common TLS configuration are found before) ;ENABLE_ACME = false @@ -503,9 +501,6 @@ INTERNAL_TOKEN = ;; Password Hash algorithm, either "argon2", "pbkdf2", "scrypt" or "bcrypt" ;PASSWORD_HASH_ALGO = pbkdf2 ;; -;; Set false to allow JavaScript to read CSRF cookie -;CSRF_COOKIE_HTTP_ONLY = true -;; ;; Validate against https://haveibeenpwned.com/Passwords to see if a password has been exposed ;PASSWORD_CHECK_PWN = false ;; @@ -524,6 +519,9 @@ INTERNAL_TOKEN = ;; Set the two-factor auth behavior. ;; Set to "enforced", to force users to enroll into Two-Factor Authentication, users without 2FA have no access to repositories via API or web. ;TWO_FACTOR_AUTH = +;; +;; The value of the X-Frame-Options HTTP header for HTML responses. Use "unset" to remove the header. +;X_FRAME_OPTIONS = SAMEORIGIN ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -567,6 +565,11 @@ ENABLED = true ;; Alternative location to specify OAuth2 authentication secret. You cannot specify both this and JWT_SECRET, and must pick one ;JWT_SECRET_URI = file:/etc/gitea/oauth2_jwt_secret ;; +;; The "issuer" claim identifies the principal that issued the JWT. +;; Gitea 1.25 makes it default to "ROOT_URL without the last slash" to follow the standard. +;; If you have old logins from before 1.25, you may want to set it to the old (non-standard) value "ROOT_URL with the last slash". +;JWT_CLAIM_ISSUER = +;; ;; Lifetime of an OAuth2 access token in seconds ;ACCESS_TOKEN_EXPIRATION_TIME = 3600 ;; @@ -728,15 +731,15 @@ LEVEL = Info ;DISABLE_CORE_PROTECT_NTFS=false ;; Disable the usage of using partial clones for git. ;DISABLE_PARTIAL_CLONE = false +;; Set the similarity threshold passed to git commands via `--find-renames=`. +;; Default is 50%, the same as git. Must be a integer percentage between 0% and 100%. +;DIFF_RENAME_SIMILARITY_THRESHOLD = 50% ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Git Operation timeout in seconds ;[git.timeout] -;DEFAULT = 360 ;MIGRATE = 600 ;MIRROR = 300 -;CLONE = 300 -;PULL = 300 ;GC = 60 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -1178,7 +1181,15 @@ LEVEL = Info ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; 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 = +;; +;; Number of releases that are displayed on release page ;DEFAULT_PAGING_NUM = 10 +;; +;; Max size of each file in megabytes. Defaults to 2GB +;FILE_MAX_SIZE = 2048 +;; +;; Max number of files per upload. Defaults to 5 +;MAX_FILES = 5 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -1280,9 +1291,6 @@ LEVEL = Info ;; ;; headers to permit ;HEADERS = Content-Type,User-Agent -;; -;; set X-FRAME-OPTIONS header -;X_FRAME_OPTIONS = SAMEORIGIN ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -1327,9 +1335,12 @@ LEVEL = Info ;; Leave it empty to allow users to select any theme from "{CustomPath}/public/assets/css/theme-*.css" ;THEMES = ;; -;; The icons for file list (basic/material), this is a temporary option which will be replaced by a user setting in the future. +;; The icon theme for files (basic/material) ;FILE_ICON_THEME = material ;; +;; The icon theme for folders (basic/material) +;FOLDER_ICON_THEME = basic +;; ;; All available reactions users can choose on issues/prs and comments. ;; Values can be emoji alias (:smile:) or a unicode emoji. ;; For custom reactions, add a tightly cropped square image to public/assets/img/emoji/reaction_name.png @@ -1343,6 +1354,10 @@ LEVEL = Info ;; Dont mistake it for Reactions. ;CUSTOM_EMOJIS = gitea, codeberg, gitlab, git, github, gogs ;; +;; Comma separated list of enabled emojis, for example: smile, thumbsup, thumbsdown +;; Leave it empty to enable all emojis. +;ENABLED_EMOJIS = +;; ;; Whether the full name of the users should be shown where possible. If the full name isn't set, the username will be used. ;DEFAULT_SHOW_FULL_NAME = false ;; @@ -1966,12 +1981,12 @@ LEVEL = Info ;; or a custom avatar source, like: http://cn.gravatar.com/avatar/ ;GRAVATAR_SOURCE = gravatar ;; -;; This value will always be true in offline mode. +;; Deprecated, see Web UI Admin Panel -> Config -> Settings ;DISABLE_GRAVATAR = false ;; ;; Federated avatar lookup uses DNS to discover avatar associated ;; with emails, see https://www.libravatar.org -;; This value will always be false in offline mode or when Gravatar is disabled. +;; Deprecated, see Web UI Admin Panel -> Config -> Settings ;ENABLE_FEDERATED_AVATAR = false ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -1986,8 +2001,8 @@ LEVEL = Info ;; 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 = .avif,.cpuprofile,.csv,.dmp,.docx,.fodg,.fodp,.fods,.fodt,.gif,.gz,.jpeg,.jpg,.json,.jsonc,.log,.md,.mov,.mp4,.odf,.odg,.odp,.ods,.odt,.patch,.pdf,.png,.pptx,.svg,.tgz,.txt,.webm,.webp,.xls,.xlsx,.zip ;; -;; Max size of each file. Defaults to 2048MB -;MAX_SIZE = 2048 +;; Max size of each file. Defaults to 100MB +;MAX_SIZE = 100 ;; ;; Max number of files per upload. Defaults to 5 ;MAX_FILES = 5 @@ -2325,7 +2340,7 @@ LEVEL = Info ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Resynchronize pre-receive, update and post-receive hooks of all repositories. +;; Resynchronize git hooks of all repositories (pre-receive, update, post-receive, proc-receive, ...) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;[cron.resync_all_hooks] ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -2479,8 +2494,9 @@ LEVEL = Info ;[highlight.mapping] ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Extension mapping to highlight class -;; e.g. .toml=ini +;; Extension mapping to highlight class, for example: +;; .toml = ini +;; .my-js = JavaScript ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -2536,7 +2552,19 @@ LEVEL = Info ;; * sanitized: Sanitize the content and render it inside current page, default to only allow a few HTML tags and attributes. Customized sanitizer rules can be defined in [markup.sanitizer.*] . ;; * no-sanitizer: Disable the sanitizer and render the content inside current page. It's **insecure** and may lead to XSS attack if the content contains malicious code. ;; * iframe: Render the content in a separate standalone page and embed it into current page by iframe. The iframe is in sandbox mode with same-origin disabled, and the JS code are safely isolated from parent page. -;RENDER_CONTENT_MODE=sanitized +;RENDER_CONTENT_MODE = sanitized +;; The sandbox applied to the iframe and Content-Security-Policy header when RENDER_CONTENT_MODE is `iframe`. +;; It defaults to a safe set of "allow-*" restrictions (space separated). +;; You can also set it by your requirements or use "disabled" to disable the sandbox completely. +;; When set it, make sure there is no security risk: +;; * PDF-only content: generally safe to use "disabled", and it needs to be "disabled" because PDF only renders with no sandbox. +;; * HTML content with JS: if the "RENDER_COMMAND" can guarantee there is no XSS, then it is safe, otherwise, you need to fine tune the "allow-*" restrictions. +;RENDER_CONTENT_SANDBOX = +;; Whether post-process the rendered HTML content, including: +;; resolve relative links and image sources, recognizing issue/commit references, escaping invisible characters, +;; mentioning users, rendering permlink code blocks, replacing emoji shorthands, etc. +;; By default, this is true when RENDER_CONTENT_MODE is `sanitized`, otherwise false. +;NEED_POST_PROCESS = false ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -2836,6 +2864,9 @@ LEVEL = Info ;ABANDONED_JOB_TIMEOUT = 24h ;; Strings committers can place inside a commit message or PR title to skip executing the corresponding actions workflow ;SKIP_WORKFLOW_STRINGS = [skip ci],[ci skip],[no ci],[skip actions],[actions skip] +;; Comma-separated list of workflow directories, the first one to exist +;; in a repo is used to find Actions workflow files +;WORKFLOW_DIRS = .gitea/workflows,.github/workflows ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/docker/root/usr/local/bin/environment-to-ini b/docker/root/usr/local/bin/environment-to-ini new file mode 100644 index 00000000000..bb0c540685c --- /dev/null +++ b/docker/root/usr/local/bin/environment-to-ini @@ -0,0 +1,2 @@ +#!/bin/bash +exec /app/gitea/gitea config edit-ini --in-place --apply-env "$@" diff --git a/docker/rootless/usr/local/bin/environment-to-ini b/docker/rootless/usr/local/bin/environment-to-ini new file mode 100644 index 00000000000..bb0c540685c --- /dev/null +++ b/docker/rootless/usr/local/bin/environment-to-ini @@ -0,0 +1,2 @@ +#!/bin/bash +exec /app/gitea/gitea config edit-ini --in-place --apply-env "$@" diff --git a/eslint.config.ts b/eslint.config.ts index 678a49647c0..8ed0cf789a7 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -3,7 +3,6 @@ import comments from '@eslint-community/eslint-plugin-eslint-comments'; import github from 'eslint-plugin-github'; import globals from 'globals'; import importPlugin from 'eslint-plugin-import-x'; -import noUseExtendNative from 'eslint-plugin-no-use-extend-native'; import playwright from 'eslint-plugin-playwright'; import regexp from 'eslint-plugin-regexp'; import sonarjs from 'eslint-plugin-sonarjs'; @@ -16,10 +15,22 @@ import vue from 'eslint-plugin-vue'; import vueScopedCss from 'eslint-plugin-vue-scoped-css'; import wc from 'eslint-plugin-wc'; import {defineConfig, globalIgnores} from 'eslint/config'; +import type {ESLint} from 'eslint'; const jsExts = ['js', 'mjs', 'cjs'] as const; const tsExts = ['ts', 'mts', 'cts'] as const; -const restrictedSyntax = ['WithStatement', 'ForInStatement', 'LabeledStatement', 'SequenceExpression']; + +const restrictedGlobals = [ + {name: 'localStorage', message: 'Use `modules/user-settings.ts` instead.'}, + {name: 'fetch', message: 'Use `modules/fetch.ts` instead.'}, +]; + +const restrictedProperties = [ + {object: 'window', property: 'localStorage', message: 'Use `modules/user-settings.ts` instead.'}, + {object: 'globalThis', property: 'localStorage', message: 'Use `modules/user-settings.ts` instead.'}, + {object: 'window', property: 'fetch', message: 'Use `modules/fetch.ts` instead.'}, + {object: 'globalThis', property: 'fetch', message: 'Use `modules/fetch.ts` instead.'}, +]; export default defineConfig([ globalIgnores([ @@ -33,10 +44,6 @@ export default defineConfig([ languageOptions: { ecmaVersion: 'latest', sourceType: 'module', - globals: { - ...globals.browser, - ...globals.node, - }, parser: typescriptParser, parserOptions: { sourceType: 'module', @@ -49,24 +56,18 @@ export default defineConfig([ }, linterOptions: { reportUnusedDisableDirectives: 2, + reportUnusedInlineConfigs: 2, }, plugins: { '@eslint-community/eslint-comments': comments, - // @ts-expect-error '@stylistic': stylistic, '@typescript-eslint': typescriptPlugin.plugin, 'array-func': arrayFunc, - // @ts-expect-error -- https://github.com/un-ts/eslint-plugin-import-x/issues/203 - 'import-x': importPlugin, - 'no-use-extend-native': noUseExtendNative, - // @ts-expect-error + 'import-x': importPlugin as unknown as ESLint.Plugin, // https://github.com/un-ts/eslint-plugin-import-x/issues/203 regexp, - // @ts-expect-error sonarjs, - // @ts-expect-error unicorn, github, - // @ts-expect-error wc, }, settings: { @@ -75,7 +76,7 @@ export default defineConfig([ 'import-x/resolver': {'typescript': true}, }, rules: { - '@eslint-community/eslint-comments/disable-enable-pair': [2], + '@eslint-community/eslint-comments/disable-enable-pair': [0], '@eslint-community/eslint-comments/no-aggregating-enable': [2], '@eslint-community/eslint-comments/no-duplicate-disable': [2], '@eslint-community/eslint-comments/no-restricted-disable': [0], @@ -155,11 +156,11 @@ export default defineConfig([ '@typescript-eslint/adjacent-overload-signatures': [0], '@typescript-eslint/array-type': [0], '@typescript-eslint/await-thenable': [2], - '@typescript-eslint/ban-ts-comment': [2, {'ts-expect-error': false, 'ts-ignore': true, 'ts-nocheck': false, 'ts-check': false}], + '@typescript-eslint/ban-ts-comment': [2, {'ts-expect-error': true, 'ts-ignore': true, 'ts-nocheck': false, 'ts-check': false}], '@typescript-eslint/ban-tslint-comment': [0], '@typescript-eslint/class-literal-property-style': [0], '@typescript-eslint/class-methods-use-this': [0], - '@typescript-eslint/consistent-generic-constructors': [0], + '@typescript-eslint/consistent-generic-constructors': [2, 'constructor'], '@typescript-eslint/consistent-indexed-object-style': [0], '@typescript-eslint/consistent-return': [0], '@typescript-eslint/consistent-type-assertions': [2, {assertionStyle: 'as', objectLiteralTypeAssertions: 'allow'}], @@ -210,7 +211,7 @@ export default defineConfig([ '@typescript-eslint/no-non-null-asserted-nullish-coalescing': [0], '@typescript-eslint/no-non-null-asserted-optional-chain': [2], '@typescript-eslint/no-non-null-assertion': [0], - '@typescript-eslint/no-redeclare': [0], + '@typescript-eslint/no-redeclare': [2], '@typescript-eslint/no-redundant-type-constituents': [2], '@typescript-eslint/no-require-imports': [2], '@typescript-eslint/no-restricted-imports': [0], @@ -221,7 +222,7 @@ export default defineConfig([ '@typescript-eslint/no-unnecessary-condition': [0], '@typescript-eslint/no-unnecessary-qualifier': [0], '@typescript-eslint/no-unnecessary-template-expression': [0], - '@typescript-eslint/no-unnecessary-type-arguments': [0], + '@typescript-eslint/no-unnecessary-type-arguments': [2], '@typescript-eslint/no-unnecessary-type-assertion': [2], '@typescript-eslint/no-unnecessary-type-constraint': [2], '@typescript-eslint/no-unnecessary-type-conversion': [2], @@ -234,10 +235,12 @@ export default defineConfig([ '@typescript-eslint/no-unsafe-member-access': [0], '@typescript-eslint/no-unsafe-return': [0], '@typescript-eslint/no-unsafe-unary-minus': [2], - '@typescript-eslint/no-unused-expressions': [0], + '@typescript-eslint/no-unused-expressions': [2], + '@typescript-eslint/no-unused-private-class-members': [2], '@typescript-eslint/no-unused-vars': [2, {vars: 'all', args: 'all', caughtErrors: 'all', ignoreRestSiblings: false, argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_', destructuredArrayIgnorePattern: '^_'}], '@typescript-eslint/no-use-before-define': [2, {functions: false, classes: true, variables: true, allowNamedExports: true, typedefs: false, enums: false, ignoreTypeReferences: true}], '@typescript-eslint/no-useless-constructor': [0], + '@typescript-eslint/no-useless-default-assignment': [2], '@typescript-eslint/no-useless-empty-export': [0], '@typescript-eslint/no-wrapper-object-types': [2], '@typescript-eslint/non-nullable-type-assertion-style': [0], @@ -268,6 +271,7 @@ export default defineConfig([ '@typescript-eslint/restrict-template-expressions': [0], '@typescript-eslint/return-await': [0], '@typescript-eslint/strict-boolean-expressions': [0], + '@typescript-eslint/strict-void-return': [0], '@typescript-eslint/switch-exhaustiveness-check': [0], '@typescript-eslint/triple-slash-reference': [2], '@typescript-eslint/typedef': [0], @@ -338,7 +342,7 @@ export default defineConfig([ 'import-x/first': [2], 'import-x/group-exports': [0], 'import-x/max-dependencies': [0], - 'import-x/named': [2], + 'import-x/named': [0], 'import-x/namespace': [0], 'import-x/newline-after-import': [0], 'import-x/no-absolute-path': [0], @@ -366,7 +370,7 @@ export default defineConfig([ 'import-x/no-self-import': [2], 'import-x/no-unassigned-import': [0], 'import-x/no-unresolved': [2, {commonjs: true, ignore: ['\\?.+$']}], - // 'import-x/no-unused-modules': [2, {unusedExports: true}], // not compatible with eslint 9 + 'import-x/no-unused-modules': [0], // incompatible with eslint 9 'import-x/no-useless-path-segments': [2, {commonjs: true}], 'import-x/no-webpack-loader-syntax': [2], 'import-x/order': [0], @@ -433,7 +437,7 @@ export default defineConfig([ 'no-import-assign': [2], 'no-inline-comments': [0], 'no-inner-declarations': [2], - 'no-invalid-regexp': [2], + 'no-invalid-regexp': [0], // handled by regexp/no-invalid-regexp 'no-invalid-this': [0], 'no-irregular-whitespace': [2], 'no-iterator': [2], @@ -547,7 +551,7 @@ export default defineConfig([ 'no-new-func': [0], // handled by @typescript-eslint/no-implied-eval 'no-new-native-nonconstructor': [2], 'no-new-object': [2], - 'no-new-symbol': [2], + 'no-new-symbol': [0], // handled by no-new-native-nonconstructor 'no-new-wrappers': [2], 'no-new': [0], 'no-nonoctal-decimal-escape': [2], @@ -562,9 +566,10 @@ export default defineConfig([ 'no-redeclare': [0], // must be disabled for typescript overloads 'no-regex-spaces': [2], 'no-restricted-exports': [0], - 'no-restricted-globals': [2, 'addEventListener', 'blur', 'close', 'closed', 'confirm', 'defaultStatus', 'defaultstatus', 'error', 'event', 'external', 'find', 'focus', 'frameElement', 'frames', 'history', 'innerHeight', 'innerWidth', 'isFinite', 'isNaN', 'length', 'locationbar', 'menubar', 'moveBy', 'moveTo', 'name', 'onblur', 'onerror', 'onfocus', 'onload', 'onresize', 'onunload', 'open', 'opener', 'opera', 'outerHeight', 'outerWidth', 'pageXOffset', 'pageYOffset', 'parent', 'print', 'removeEventListener', 'resizeBy', 'resizeTo', 'screen', 'screenLeft', 'screenTop', 'screenX', 'screenY', 'scroll', 'scrollbars', 'scrollBy', 'scrollTo', 'scrollX', 'scrollY', 'status', 'statusbar', 'stop', 'toolbar', 'top'], + 'no-restricted-globals': [2, ...restrictedGlobals], + 'no-restricted-properties': [2, ...restrictedProperties], 'no-restricted-imports': [0], - 'no-restricted-syntax': [2, ...restrictedSyntax, {selector: 'CallExpression[callee.name="fetch"]', message: 'use modules/fetch.ts instead'}], + 'no-restricted-syntax': [2, 'WithStatement', 'ForInStatement', 'LabeledStatement', 'SequenceExpression'], 'no-return-assign': [0], 'no-script-url': [2], 'no-self-assign': [2, {props: true}], @@ -577,7 +582,7 @@ export default defineConfig([ 'no-template-curly-in-string': [2], 'no-ternary': [0], 'no-this-before-super': [2], - 'no-throw-literal': [2], + 'no-throw-literal': [0], // handled by @typescript-eslint/only-throw-error 'no-undef-init': [2], 'no-undef': [2], // it is still needed by eslint & IDE to prompt undefined names in real time 'no-undefined': [0], @@ -589,13 +594,13 @@ export default defineConfig([ 'no-unreachable': [2], 'no-unsafe-finally': [2], 'no-unsafe-negation': [2], - 'no-unused-expressions': [2], + 'no-unused-expressions': [0], // handled by @typescript-eslint/no-unused-expressions 'no-unused-labels': [2], - 'no-unused-private-class-members': [2], + 'no-unused-private-class-members': [0], // handled by @typescript-eslint/no-unused-private-class-members 'no-unused-vars': [0], // handled by @typescript-eslint/no-unused-vars 'no-use-before-define': [0], // handled by @typescript-eslint/no-use-before-define - 'no-use-extend-native/no-use-extend-native': [2], - 'no-useless-backreference': [2], + 'no-useless-assignment': [2], + 'no-useless-backreference': [0], // handled by regexp/no-useless-backreference 'no-useless-call': [2], 'no-useless-catch': [2], 'no-useless-computed-key': [2], @@ -603,7 +608,7 @@ export default defineConfig([ 'no-useless-constructor': [2], 'no-useless-escape': [2], 'no-useless-rename': [2], - 'no-useless-return': [2], + 'no-useless-return': [0], // handled by sonarjs/no-redundant-jump 'no-var': [2], 'no-void': [2], 'no-warning-comments': [0], @@ -612,7 +617,7 @@ export default defineConfig([ 'one-var-declaration-per-line': [0], 'one-var': [0], 'operator-assignment': [2, 'always'], - 'operator-linebreak': [2, 'after'], + 'operator-linebreak': [0], // handled by @stylistic/operator-linebreak 'prefer-arrow-callback': [2, {allowNamedFunctions: true, allowUnboundThis: true}], 'prefer-const': [2, {destructuring: 'all', ignoreReadBeforeAssign: true}], 'prefer-destructuring': [0], @@ -692,7 +697,7 @@ export default defineConfig([ 'regexp/prefer-question-quantifier': [2], 'regexp/prefer-range': [2], 'regexp/prefer-regexp-exec': [2], - 'regexp/prefer-regexp-test': [2], + 'regexp/prefer-regexp-test': [0], // handled by unicorn/prefer-regexp-test 'regexp/prefer-result-array-groups': [0], 'regexp/prefer-set-operation': [2], 'regexp/prefer-star-quantifier': [2], @@ -722,7 +727,7 @@ export default defineConfig([ 'sonarjs/no-empty-collection': [2], 'sonarjs/no-extra-arguments': [2], 'sonarjs/no-gratuitous-expressions': [2], - 'sonarjs/no-identical-conditions': [2], + 'sonarjs/no-identical-conditions': [0], // handled by no-dupe-else-if 'sonarjs/no-identical-expressions': [2], 'sonarjs/no-identical-functions': [2, 5], 'sonarjs/no-ignored-return': [2], @@ -735,7 +740,7 @@ export default defineConfig([ 'sonarjs/no-small-switch': [0], 'sonarjs/no-unused-collection': [2], 'sonarjs/no-use-of-empty-return-value': [2], - 'sonarjs/no-useless-catch': [2], + 'sonarjs/no-useless-catch': [0], // handled by no-useless-catch 'sonarjs/non-existent-operator': [2], 'sonarjs/prefer-immediate-return': [0], 'sonarjs/prefer-object-literal': [0], @@ -762,6 +767,7 @@ export default defineConfig([ 'unicorn/filename-case': [0], 'unicorn/import-index': [0], 'unicorn/import-style': [0], + 'unicorn/isolated-functions': [2, {functions: []}], 'unicorn/new-for-builtins': [2], 'unicorn/no-abusive-eslint-disable': [0], 'unicorn/no-anonymous-default-export': [0], @@ -777,6 +783,7 @@ export default defineConfig([ 'unicorn/no-empty-file': [2], 'unicorn/no-for-loop': [0], 'unicorn/no-hex-escape': [0], + 'unicorn/no-immediate-mutation': [0], 'unicorn/no-instanceof-array': [0], 'unicorn/no-invalid-fetch-options': [2], 'unicorn/no-invalid-remove-event-listener': [2], @@ -800,8 +807,9 @@ export default defineConfig([ 'unicorn/no-unnecessary-await': [2], 'unicorn/no-unnecessary-polyfills': [2], 'unicorn/no-unreadable-array-destructuring': [0], - 'unicorn/no-unreadable-iife': [2], + 'unicorn/no-unreadable-iife': [0], 'unicorn/no-unused-properties': [2], + 'unicorn/no-useless-collection-argument': [2], 'unicorn/no-useless-fallback-in-spread': [2], 'unicorn/no-useless-length-check': [2], 'unicorn/no-useless-promise-resolve-reject': [2], @@ -812,9 +820,9 @@ export default defineConfig([ 'unicorn/number-literal-case': [0], 'unicorn/numeric-separators-style': [0], 'unicorn/prefer-add-event-listener': [2], - 'unicorn/prefer-array-find': [2], - 'unicorn/prefer-array-flat-map': [2], + 'unicorn/prefer-array-find': [0], // handled by @typescript-eslint/prefer-find 'unicorn/prefer-array-flat': [2], + 'unicorn/prefer-array-flat-map': [2], 'unicorn/prefer-array-index-of': [2], 'unicorn/prefer-array-some': [2], 'unicorn/prefer-at': [0], @@ -829,7 +837,7 @@ export default defineConfig([ 'unicorn/prefer-event-target': [2], 'unicorn/prefer-export-from': [0], 'unicorn/prefer-global-this': [0], - 'unicorn/prefer-includes': [2], + 'unicorn/prefer-includes': [0], // handled by @typescript-eslint/prefer-includes 'unicorn/prefer-json-parse-buffer': [0], 'unicorn/prefer-keyboard-event-key': [2], 'unicorn/prefer-logical-operator-over-ternary': [2], @@ -849,13 +857,14 @@ export default defineConfig([ 'unicorn/prefer-query-selector': [2], 'unicorn/prefer-reflect-apply': [0], 'unicorn/prefer-regexp-test': [2], + 'unicorn/prefer-response-static-json': [2], 'unicorn/prefer-set-has': [0], 'unicorn/prefer-set-size': [2], 'unicorn/prefer-spread': [0], 'unicorn/prefer-string-raw': [0], 'unicorn/prefer-string-replace-all': [0], 'unicorn/prefer-string-slice': [0], - 'unicorn/prefer-string-starts-ends-with': [2], + 'unicorn/prefer-string-starts-ends-with': [0], // handled by @typescript-eslint/prefer-string-starts-ends-with 'unicorn/prefer-string-trim-start-end': [2], 'unicorn/prefer-structured-clone': [2], 'unicorn/prefer-switch': [0], @@ -900,12 +909,12 @@ export default defineConfig([ 'yoda': [2, 'never'], }, }, - // @ts-expect-error { ...playwright.configs['flat/recommended'], - files: ['tests/e2e/**'], + files: ['tests/e2e/**/*.test.ts'], rules: { ...playwright.configs['flat/recommended'].rules, + 'playwright/expect-expect': [0], }, }, { @@ -916,27 +925,19 @@ export default defineConfig([ }, }, extends: [ - // @ts-expect-error vue.configs['flat/recommended'], - // @ts-expect-error - vueScopedCss.configs['flat/recommended'], + vueScopedCss.configs.recommended as any, ], rules: { 'vue/attributes-order': [0], 'vue/html-closing-bracket-spacing': [2, {startTag: 'never', endTag: 'never', selfClosingTag: 'never'}], 'vue/max-attributes-per-line': [0], 'vue/singleline-html-element-content-newline': [0], - }, - }, - { - files: ['web_src/js/modules/fetch.ts', 'web_src/js/standalone/**/*'], - rules: { - 'no-restricted-syntax': [2, ...restrictedSyntax], + 'vue/require-typed-ref': [2], }, }, { files: ['**/*.test.ts', 'web_src/js/test/setup.ts'], - // @ts-expect-error - https://github.com/vitest-dev/eslint-plugin-vitest/issues/737 plugins: {vitest}, languageOptions: {globals: globals.vitest}, rules: { @@ -960,6 +961,7 @@ export default defineConfig([ 'vitest/no-interpolation-in-snapshots': [0], 'vitest/no-large-snapshots': [0], 'vitest/no-mocks-import': [0], + 'vitest/no-importing-vitest-globals': [2], 'vitest/no-restricted-matchers': [0], 'vitest/no-restricted-vi-methods': [0], 'vitest/no-standalone-expect': [0], @@ -988,42 +990,23 @@ export default defineConfig([ 'vitest/require-to-throw-message': [0], 'vitest/require-top-level-describe': [0], 'vitest/valid-describe-callback': [2], - 'vitest/valid-expect': [2], + 'vitest/valid-expect': [2, {maxArgs: 2}], 'vitest/valid-title': [2], }, }, - { - files: ['web_src/js/types.ts'], - rules: { - 'import-x/no-unused-modules': [0], - }, - }, { files: ['**/*.d.ts'], rules: { - 'import-x/no-unused-modules': [0], '@typescript-eslint/consistent-type-definitions': [0], '@typescript-eslint/consistent-type-imports': [0], }, }, { - files: ['*.config.*'], - rules: { - 'import-x/no-unused-modules': [0], - }, - }, - { - files: ['web_src/**/*', 'docs/**/*'], - languageOptions: {globals: globals.browser}, + files: ['*', 'tools/**/*'], + languageOptions: {globals: globals.nodeBuiltin}, }, { files: ['web_src/**/*'], - languageOptions: { - globals: { - ...globals.browser, - __webpack_public_path__: true, - process: false, // https://github.com/webpack/webpack/issues/15833 - }, - }, + languageOptions: {globals: {...globals.browser, ...globals.webpack}}, }, ]); diff --git a/eslint.json.config.ts b/eslint.json.config.ts new file mode 100644 index 00000000000..45696fb79cb --- /dev/null +++ b/eslint.json.config.ts @@ -0,0 +1,30 @@ +import {defineConfig, globalIgnores} from 'eslint/config'; +import json from '@eslint/json'; + +export default defineConfig([ + globalIgnores([ + '**/.venv', + '**/node_modules', + '**/public', + ]), + { + files: ['**/*.json'], + plugins: {json}, + language: 'json/json', + extends: ['json/recommended'], + }, + { + files: [ + 'tsconfig.json', + '.devcontainer/*.json', + '.vscode/*.json', + 'contrib/ide/vscode/*.json', + ], + plugins: {json}, + language: 'json/jsonc', + languageOptions: { + allowTrailingCommas: true, + }, + extends: ['json/recommended'], + }, +]); diff --git a/flake.lock b/flake.lock index 16a487ba133..3af57938421 100644 --- a/flake.lock +++ b/flake.lock @@ -1,30 +1,12 @@ { "nodes": { - "flake-utils": { - "inputs": { - "systems": "systems" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, "nixpkgs": { "locked": { - "lastModified": 1755186698, - "narHash": "sha256-wNO3+Ks2jZJ4nTHMuks+cxAiVBGNuEBXsT29Bz6HASo=", + "lastModified": 1773389992, + "narHash": "sha256-wvfdLLWJ2I9oEpDd9PfMA8osfIZicoQ5MT1jIwNs9Tk=", "owner": "nixos", "repo": "nixpkgs", - "rev": "fbcf476f790d8a217c3eab4e12033dc4a0f6d23c", + "rev": "c06b4ae3d6599a672a6210b7021d699c351eebda", "type": "github" }, "original": { @@ -36,24 +18,8 @@ }, "root": { "inputs": { - "flake-utils": "flake-utils", "nixpkgs": "nixpkgs" } - }, - "systems": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } } }, "root": "root", diff --git a/flake.nix b/flake.nix index 588f608ffc7..6fb38919638 100644 --- a/flake.nix +++ b/flake.nix @@ -1,73 +1,94 @@ { inputs = { nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; - flake-utils.url = "github:numtide/flake-utils"; }; outputs = - { nixpkgs, flake-utils, ... }: - flake-utils.lib.eachDefaultSystem ( - system: - let - pkgs = nixpkgs.legacyPackages.${system}; - in - { - devShells.default = - with pkgs; + { nixpkgs, ... }: + let + supportedSystems = [ + "aarch64-darwin" + "aarch64-linux" + "x86_64-darwin" + "x86_64-linux" + ]; + + forEachSupportedSystem = + f: + nixpkgs.lib.genAttrs supportedSystems ( + system: let - # only bump toolchain versions here - go = go_1_25; - nodejs = nodejs_24; - python3 = python312; - pnpm = pnpm_10; - - # Platform-specific dependencies - linuxOnlyInputs = lib.optionals pkgs.stdenv.isLinux [ - glibc.static - ]; - - linuxOnlyEnv = lib.optionalAttrs pkgs.stdenv.isLinux { - CFLAGS = "-I${glibc.static.dev}/include"; - LDFLAGS = "-L ${glibc.static}/lib"; + pkgs = import nixpkgs { + inherit system; }; in - pkgs.mkShell ( - { - buildInputs = [ - # generic - git - git-lfs - gnumake - gnused - gnutar - gzip - zip + f { inherit pkgs; } + ); + in + { + devShells = forEachSupportedSystem ( + { pkgs, ... }: + { + default = + let + inherit (pkgs) lib; - # frontend - nodejs - pnpm - cairo - pixman - pkg-config + # only bump toolchain versions here + go = pkgs.go_1_25; + nodejs = pkgs.nodejs_24; + python3 = pkgs.python312; + pnpm = pkgs.pnpm_10; - # linting - python3 - uv + # Platform-specific dependencies + linuxOnlyInputs = lib.optionals pkgs.stdenv.isLinux [ + pkgs.glibc.static + ]; - # backend - go - gofumpt - sqlite - ] - ++ linuxOnlyInputs; + linuxOnlyEnv = lib.optionalAttrs pkgs.stdenv.isLinux { + CFLAGS = "-I${pkgs.glibc.static.dev}/include"; + LDFLAGS = "-L ${pkgs.glibc.static}/lib"; + }; + in + pkgs.mkShell { + packages = + with pkgs; + [ + # generic + git + git-lfs + gnumake + gnused + gnutar + gzip + zip - GO = "${go}/bin/go"; - GOROOT = "${go}/share/go"; + # frontend + nodejs + pnpm + cairo + pixman + pkg-config - TAGS = "sqlite sqlite_unlock_notify"; - STATIC = "true"; - } - // linuxOnlyEnv - ); - } - ); + # linting + python3 + uv + + # backend + go + gofumpt + sqlite + ] + ++ linuxOnlyInputs; + + env = { + GO = "${go}/bin/go"; + GOROOT = "${go}/share/go"; + + TAGS = "sqlite sqlite_unlock_notify"; + STATIC = "true"; + } + // linuxOnlyEnv; + }; + } + ); + }; } diff --git a/go.mod b/go.mod index f32c3e08ef4..24c18d5703c 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module code.gitea.io/gitea -go 1.25.1 +go 1.26.1 // rfc5280 said: "The serial number is an integer assigned by the CA to each certificate." // But some CAs use negative serial number, just relax the check. related: @@ -9,14 +9,13 @@ godebug x509negativeserial=1 require ( code.gitea.io/actions-proto-go v0.4.1 - code.gitea.io/gitea-vet v0.2.3 - code.gitea.io/sdk/gitea v0.22.0 + code.gitea.io/sdk/gitea v0.23.2 codeberg.org/gusted/mcaptcha v0.0.0-20220723083913-4f3072e1d570 - connectrpc.com/connect v1.18.1 + connectrpc.com/connect v1.19.1 gitea.com/go-chi/binding v0.0.0-20240430071103-39a851e106ed gitea.com/go-chi/cache v0.2.1 gitea.com/go-chi/captcha v0.0.0-20240315150714-fb487f629098 - gitea.com/go-chi/session v0.0.0-20240316035857-16768d98ec96 + gitea.com/go-chi/session v0.0.0-20251124165456-68e0254e989e gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96 gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4 github.com/42wim/httpsig v1.2.3 @@ -25,80 +24,76 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.2 github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 github.com/ProtonMail/go-crypto v1.3.0 - github.com/PuerkitoBio/goquery v1.10.3 + github.com/PuerkitoBio/goquery v1.11.0 github.com/SaveTheRbtz/zstd-seekable-format-go/pkg v0.8.0 - github.com/alecthomas/chroma/v2 v2.20.0 - github.com/aws/aws-sdk-go-v2/credentials v1.18.10 - github.com/aws/aws-sdk-go-v2/service/codecommit v1.32.2 + github.com/alecthomas/chroma/v2 v2.23.1 + github.com/aws/aws-sdk-go-v2/credentials v1.19.7 + github.com/aws/aws-sdk-go-v2/service/codecommit v1.33.8 github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb - github.com/blevesearch/bleve/v2 v2.5.3 + github.com/blevesearch/bleve/v2 v2.5.7 github.com/bohde/codel v0.2.0 github.com/buildkite/terminal-to-html/v3 v3.16.8 - github.com/caddyserver/certmagic v0.24.0 - github.com/charmbracelet/git-lfs-transfer v0.2.0 + github.com/caddyserver/certmagic v0.25.1 + github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20251013092601-6327009efd21 github.com/chi-middleware/proxy v1.1.1 github.com/dimiro1/reply v0.0.0-20200315094148-d0136a4c9e21 - github.com/djherbis/buffer v1.2.0 - github.com/djherbis/nio/v3 v3.0.1 + github.com/dlclark/regexp2 v1.11.5 github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 github.com/dustin/go-humanize v1.0.1 - github.com/editorconfig/editorconfig-core-go/v2 v2.6.3 + github.com/editorconfig/editorconfig-core-go/v2 v2.6.4 github.com/emersion/go-imap v1.2.1 github.com/emirpasic/gods v1.18.1 github.com/ethantkoenig/rupture v1.0.1 github.com/felixge/fgprof v0.9.5 github.com/fsnotify/fsnotify v1.9.0 github.com/gliderlabs/ssh v0.3.8 - github.com/go-ap/activitypub v0.0.0-20250810115208-cb73b20a1742 - github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73 - github.com/go-chi/chi/v5 v5.2.3 + github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/cors v1.2.2 github.com/go-co-op/gocron v1.37.0 - github.com/go-enry/go-enry/v2 v2.9.2 - github.com/go-git/go-billy/v5 v5.6.2 - github.com/go-git/go-git/v5 v5.16.2 - github.com/go-ldap/ldap/v3 v3.4.11 - github.com/go-redsync/redsync/v4 v4.13.0 + github.com/go-enry/go-enry/v2 v2.9.4 + github.com/go-git/go-billy/v5 v5.7.0 + github.com/go-git/go-git/v5 v5.16.5 + github.com/go-ldap/ldap/v3 v3.4.12 + github.com/go-redsync/redsync/v4 v4.15.0 github.com/go-sql-driver/mysql v1.9.3 github.com/go-webauthn/webauthn v0.13.4 + github.com/goccy/go-json v0.10.5 github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f github.com/gogs/go-gogs-client v0.0.0-20210131175652-1d7215cd8d85 - github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/go-github/v74 v74.0.0 github.com/google/licenseclassifier/v2 v2.0.0 - github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 + github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef github.com/google/uuid v1.6.0 github.com/gorilla/feeds v1.2.0 github.com/gorilla/sessions v1.4.0 - github.com/hashicorp/go-version v1.7.0 + github.com/hashicorp/go-version v1.8.0 github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/huandu/xstrings v1.5.0 github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 github.com/jhillyerd/enmime v1.3.0 - github.com/json-iterator/go v1.1.12 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 - github.com/klauspost/compress v1.18.0 + github.com/klauspost/compress v1.18.3 github.com/klauspost/cpuid/v2 v2.3.0 - github.com/lib/pq v1.10.9 + github.com/lib/pq v1.11.1 github.com/markbates/goth v1.82.0 github.com/mattn/go-isatty v0.0.20 - github.com/mattn/go-sqlite3 v1.14.32 - github.com/meilisearch/meilisearch-go v0.33.2 - github.com/mholt/archives v0.1.3 + github.com/mattn/go-sqlite3 v1.14.33 + github.com/meilisearch/meilisearch-go v0.36.0 + github.com/mholt/archives v0.1.5 github.com/microcosm-cc/bluemonday v1.0.27 - github.com/microsoft/go-mssqldb v1.9.3 - github.com/minio/minio-go/v7 v7.0.95 + github.com/microsoft/go-mssqldb v1.9.6 + github.com/minio/minio-go/v7 v7.0.98 github.com/msteinert/pam v1.2.0 github.com/nektos/act v0.2.63 github.com/niklasfasching/go-org v1.9.1 github.com/olivere/elastic/v7 v7.0.32 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.1 - github.com/pkg/errors v0.9.1 github.com/pquerna/otp v1.5.0 - github.com/prometheus/client_golang v1.23.0 + github.com/prometheus/client_golang v1.23.2 github.com/quasoft/websspi v1.1.2 - github.com/redis/go-redis/v9 v9.12.1 + github.com/redis/go-redis/v9 v9.17.3 github.com/robfig/cron/v3 v3.0.1 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 github.com/sassoftware/go-rpmutils v0.4.0 @@ -109,57 +104,58 @@ require ( github.com/ulikunitz/xz v0.5.15 github.com/urfave/cli-docs/v3 v3.0.0-alpha6 github.com/urfave/cli/v3 v3.4.1 - github.com/wneessen/go-mail v0.6.2 + github.com/wneessen/go-mail v0.7.2 github.com/xeipuuv/gojsonschema v1.2.0 github.com/yohcop/openid-go v1.0.1 - github.com/yuin/goldmark v1.7.13 + github.com/yuin/goldmark v1.7.16 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc github.com/yuin/goldmark-meta v1.1.0 gitlab.com/gitlab-org/api/client-go v0.142.4 - golang.org/x/crypto v0.41.0 - golang.org/x/image v0.30.0 - golang.org/x/net v0.43.0 - golang.org/x/oauth2 v0.30.0 - golang.org/x/sync v0.16.0 - golang.org/x/sys v0.35.0 - golang.org/x/text v0.28.0 - google.golang.org/grpc v1.75.0 - google.golang.org/protobuf v1.36.8 - gopkg.in/ini.v1 v1.67.0 + go.yaml.in/yaml/v4 v4.0.0-rc.2 + golang.org/x/crypto v0.47.0 + golang.org/x/image v0.35.0 + golang.org/x/net v0.49.0 + golang.org/x/oauth2 v0.34.0 + golang.org/x/sync v0.19.0 + golang.org/x/sys v0.40.0 + golang.org/x/text v0.33.0 + google.golang.org/grpc v1.78.0 + google.golang.org/protobuf v1.36.11 + gopkg.in/ini.v1 v1.67.1 gopkg.in/yaml.v3 v3.0.1 mvdan.cc/xurls/v2 v2.6.0 strk.kbt.io/projects/go/libravatar v0.0.0-20191008002943-06d1c002b251 xorm.io/builder v0.3.13 - xorm.io/xorm v1.3.10 + xorm.io/xorm v1.3.11 ) require ( - cloud.google.com/go/compute/metadata v0.8.0 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + code.gitea.io/gitea-vet v0.2.3 // indirect dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.1.0 // indirect - git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect github.com/DataDog/zstd v1.5.7 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/RoaringBitmap/roaring/v2 v2.10.0 // indirect - github.com/STARRY-S/zip v0.2.1 // indirect + github.com/STARRY-S/zip v0.2.3 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect - github.com/aws/aws-sdk-go-v2 v1.38.3 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 // indirect - github.com/aws/smithy-go v1.23.0 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.1 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect + github.com/aws/smithy-go v1.24.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.24.0 // indirect - github.com/blevesearch/bleve_index_api v1.2.9 // indirect + github.com/blevesearch/bleve_index_api v1.2.11 // indirect github.com/blevesearch/geo v0.2.4 // indirect - github.com/blevesearch/go-faiss v1.0.25 // indirect + github.com/blevesearch/go-faiss v1.0.26 // indirect github.com/blevesearch/go-porterstemmer v1.0.3 // indirect github.com/blevesearch/gtreap v0.1.1 // indirect github.com/blevesearch/mmap-go v1.0.4 // indirect - github.com/blevesearch/scorch_segment_api/v2 v2.3.11 // indirect + github.com/blevesearch/scorch_segment_api/v2 v2.3.13 // indirect github.com/blevesearch/segment v0.9.1 // indirect github.com/blevesearch/snowballstem v0.9.0 // indirect github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect @@ -169,17 +165,17 @@ require ( github.com/blevesearch/zapx/v13 v13.4.2 // indirect github.com/blevesearch/zapx/v14 v14.4.2 // indirect github.com/blevesearch/zapx/v15 v15.4.2 // indirect - github.com/blevesearch/zapx/v16 v16.2.4 // indirect + github.com/blevesearch/zapx/v16 v16.2.8 // indirect github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect github.com/bodgit/plumbing v1.3.0 // indirect - github.com/bodgit/sevenzip v1.6.0 // indirect + github.com/bodgit/sevenzip v1.6.1 // indirect github.com/bodgit/windows v1.0.1 // indirect github.com/boombuler/barcode v1.1.0 // indirect github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf // indirect - github.com/caddyserver/zerossl v0.1.3 // indirect + github.com/caddyserver/zerossl v0.1.4 // indirect github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/cloudflare/circl v1.6.1 // indirect + github.com/cloudflare/circl v1.6.3 // indirect github.com/couchbase/go-couchbase v0.1.1 // indirect github.com/couchbase/gomemcached v0.3.3 // indirect github.com/couchbase/goutils v0.1.2 // indirect @@ -188,24 +184,19 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davidmz/go-pageant v1.0.2 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/dlclark/regexp2 v1.11.5 // indirect github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect github.com/fatih/color v1.18.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1 // indirect - github.com/go-ap/errors v0.0.0-20250527110557-c8db454e53fd // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/go-enry/go-oniguruma v1.2.1 // indirect github.com/go-fed/httpsig v1.1.1-0.20201223112313-55836744818e // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/go-webauthn/x v0.1.24 // indirect - github.com/goccy/go-json v0.10.5 // indirect - github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect - github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/flatbuffers v25.2.10+incompatible // indirect @@ -220,7 +211,9 @@ require ( github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect github.com/kevinburke/ssh_config v1.4.0 // indirect + github.com/klauspost/crc32 v1.3.0 // indirect github.com/klauspost/pgzip v1.2.6 // indirect github.com/libdns/libdns v1.1.1 // indirect github.com/mailru/easyjson v0.9.0 // indirect @@ -228,19 +221,19 @@ require ( github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-shellwords v1.0.12 // indirect - github.com/mholt/acmez/v3 v3.1.2 // indirect - github.com/miekg/dns v1.1.68 // indirect + github.com/mholt/acmez/v3 v3.1.4 // indirect + github.com/miekg/dns v1.1.69 // indirect github.com/mikelolasagasti/xz v1.0.1 // indirect github.com/minio/crc64nvme v1.1.1 // indirect github.com/minio/md5-simd v1.1.2 // indirect - github.com/minio/minlz v1.0.0 // indirect + github.com/minio/minlz v1.0.1 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450 // indirect github.com/mschoch/smat v0.2.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/nwaples/rardecode/v2 v2.1.0 // indirect + github.com/nwaples/rardecode/v2 v2.2.0 // indirect github.com/olekukonko/cat v0.0.0-20250817074551-3280053e4e00 // indirect github.com/olekukonko/errors v1.1.0 // indirect github.com/olekukonko/ll v0.1.0 // indirect @@ -249,21 +242,23 @@ require ( github.com/philhofer/fwd v1.2.0 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pjbgf/sha1cd v0.4.0 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.65.0 // indirect + github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.17.0 // indirect github.com/rhysd/actionlint v1.7.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rs/xid v1.6.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/skeema/knownhosts v1.3.1 // indirect - github.com/sorairolake/lzip-go v0.3.5 // indirect + github.com/sorairolake/lzip-go v0.3.8 // indirect + github.com/spf13/afero v1.15.0 // indirect github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect - github.com/tinylib/msgp v1.4.0 // indirect + github.com/tinylib/msgp v1.6.1 // indirect github.com/unknwon/com v1.0.1 // indirect - github.com/valyala/fastjson v1.6.4 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect @@ -274,14 +269,16 @@ require ( go.etcd.io/bbolt v1.4.3 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.0 // indirect + go.uber.org/zap v1.27.1 // indirect go.uber.org/zap/exp v0.3.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect go4.org v0.0.0-20230225012048-214862532bf5 // indirect - golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect - golang.org/x/mod v0.27.0 // indirect + golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect + golang.org/x/mod v0.31.0 // indirect golang.org/x/time v0.12.0 // indirect - golang.org/x/tools v0.36.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 // indirect + golang.org/x/tools v0.40.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) @@ -293,14 +290,7 @@ ignore ( replace github.com/jaytaylor/html2text => github.com/Necoro/html2text v0.0.0-20250804200300-7bf1ce1c7347 -replace github.com/hashicorp/go-version => github.com/6543/go-version v1.3.1 - -replace github.com/nektos/act => gitea.com/gitea/act v0.261.6 - -// TODO: the only difference is in `PutObject`: the fork doesn't use `NewVerifyingReader(r, sha256.New(), oid, expectedSize)`, need to figure out why -replace github.com/charmbracelet/git-lfs-transfer => gitea.com/gitea/git-lfs-transfer v0.2.0 - -replace git.sr.ht/~mariusor/go-xsd-duration => gitea.com/gitea/go-xsd-duration v0.0.0-20220703122237-02e73435a078 +replace github.com/nektos/act => gitea.com/gitea/act v0.261.8 exclude github.com/gofrs/uuid v3.2.0+incompatible @@ -309,3 +299,5 @@ exclude github.com/gofrs/uuid v4.0.0+incompatible exclude github.com/goccy/go-json v0.4.11 exclude github.com/satori/go.uuid v1.2.0 + +tool code.gitea.io/gitea-vet diff --git a/go.sum b/go.sum index 1853693e90d..02e65325429 100644 --- a/go.sum +++ b/go.sum @@ -9,8 +9,8 @@ cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6T cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= -cloud.google.com/go/compute/metadata v0.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA= -cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= @@ -20,31 +20,27 @@ code.gitea.io/actions-proto-go v0.4.1 h1:l0EYhjsgpUe/1VABo2eK7zcoNX2W44WOnb0MSLr code.gitea.io/actions-proto-go v0.4.1/go.mod h1:mn7Wkqz6JbnTOHQpot3yDeHx+O5C9EGhMEE+htvHBas= code.gitea.io/gitea-vet v0.2.3 h1:gdFmm6WOTM65rE8FUBTRzeQZYzXePKSSB1+r574hWwI= code.gitea.io/gitea-vet v0.2.3/go.mod h1:zcNbT/aJEmivCAhfmkHOlT645KNOf9W2KnkLgFjGGfE= -code.gitea.io/sdk/gitea v0.22.0 h1:HCKq7bX/HQ85Nw7c/HAhWgRye+vBp5nQOE8Md1+9Ef0= -code.gitea.io/sdk/gitea v0.22.0/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= +code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg= +code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= codeberg.org/gusted/mcaptcha v0.0.0-20220723083913-4f3072e1d570 h1:TXbikPqa7YRtfU9vS6QJBg77pUvbEb6StRdZO8t1bEY= codeberg.org/gusted/mcaptcha v0.0.0-20220723083913-4f3072e1d570/go.mod h1:IIAjsijsd8q1isWX8MACefDEgTQslQ4stk2AeeTt3kM= -connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw= -connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8= +connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= +connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -gitea.com/gitea/act v0.261.6 h1:CjZwKOyejonNFDmsXOw3wGm5Vet573hHM6VMLsxtvPY= -gitea.com/gitea/act v0.261.6/go.mod h1:Pg5C9kQY1CEA3QjthjhlrqOC/QOT5NyWNjOjRHw23Ok= -gitea.com/gitea/git-lfs-transfer v0.2.0 h1:baHaNoBSRaeq/xKayEXwiDQtlIjps4Ac/Ll4KqLMB40= -gitea.com/gitea/git-lfs-transfer v0.2.0/go.mod h1:UrXUCm3xLQkq15fu7qlXHUMlrhdlXHoi13KH2Dfiits= -gitea.com/gitea/go-xsd-duration v0.0.0-20220703122237-02e73435a078 h1:BAFmdZpRW7zMQZQDClaCWobRj9uL1MR3MzpCVJvc5s4= -gitea.com/gitea/go-xsd-duration v0.0.0-20220703122237-02e73435a078/go.mod h1:g/V2Hjas6Z1UHUp4yIx6bATpNzJ7DYtD0FG3+xARWxs= +gitea.com/gitea/act v0.261.8 h1:rUWB5GOZOubfe2VteKb7XP3HRIbcW3UUmfh7bVAgQcA= +gitea.com/gitea/act v0.261.8/go.mod h1:lTp4136rwbZiZS3ZVQeHCvd4qRAZ7LYeiRBqOSdMY/4= gitea.com/go-chi/binding v0.0.0-20240430071103-39a851e106ed h1:EZZBtilMLSZNWtHHcgq2mt6NSGhJSZBuduAlinMEmso= gitea.com/go-chi/binding v0.0.0-20240430071103-39a851e106ed/go.mod h1:E3i3cgB04dDx0v3CytCgRTTn9Z/9x891aet3r456RVw= gitea.com/go-chi/cache v0.2.1 h1:bfAPkvXlbcZxPCpcmDVCWoHgiBSBmZN/QosnZvEC0+g= gitea.com/go-chi/cache v0.2.1/go.mod h1:Qic0HZ8hOHW62ETGbonpwz8WYypj9NieU9659wFUJ8Q= gitea.com/go-chi/captcha v0.0.0-20240315150714-fb487f629098 h1:p2ki+WK0cIeNQuqjR98IP2KZQKRzJJiV7aTeMAFwaWo= gitea.com/go-chi/captcha v0.0.0-20240315150714-fb487f629098/go.mod h1:LjzIOHlRemuUyO7WR12fmm18VZIlCAaOt9L3yKw40pk= -gitea.com/go-chi/session v0.0.0-20240316035857-16768d98ec96 h1:IFDiMBObsP6CZIRaDLd54SR6zPYAffPXiXck5Xslu0Q= -gitea.com/go-chi/session v0.0.0-20240316035857-16768d98ec96/go.mod h1:0iEpFKnwO5dG0aF98O4eq6FMsAiXkNBaDIlUOlq4BtM= +gitea.com/go-chi/session v0.0.0-20251124165456-68e0254e989e h1:4bugwPyGMLvblEm3pZ8fZProSPVxE4l0UXF2Kv6IJoY= +gitea.com/go-chi/session v0.0.0-20251124165456-68e0254e989e/go.mod h1:KDvcfMUoXfATPHs2mbMoXFTXT45/FAFAS39waz9tPk0= gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96 h1:+wWBi6Qfruqu7xJgjOIrKVQGiLUZdpKYCZewJ4clqhw= gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96/go.mod h1:VyMQP6ue6MKHM8UsOXfNfuMKD0oSAWZdXVcpHIN2yaY= gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4 h1:IFT+hup2xejHqdhS7keYWioqfmxdnfblFDTGoOwcZ+o= @@ -55,8 +51,6 @@ github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs= github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM= github.com/42wim/sshsig v0.0.0-20250502153856-5100632e8920 h1:mWAVGlovzUfREJBhm0GwJnDNu21yRrL9QH9NIzAU3rg= github.com/42wim/sshsig v0.0.0-20250502153856-5100632e8920/go.mod h1:zWxcT7BIWOe05xVJL0VMvO/PJ6RpoCux10heb77H6Q8= -github.com/6543/go-version v1.3.1 h1:HvOp+Telns7HWJ2Xo/05YXQSB2bE0WmVgbHqwMPZT4U= -github.com/6543/go-version v1.3.1/go.mod h1:oqFAHCwtLVUTLdhQmVZWYvaHXTdsbB4SY85at64SQEo= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.0 h1:ci6Yd6nysBRLEodoziB6ah1+YOzZbZk+NYneoA6q+6E= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.0/go.mod h1:QyVsSSN64v5TGltphKLQ2sQxe4OBQg0J1eKRcVBnfgE= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4= @@ -87,26 +81,26 @@ github.com/Necoro/html2text v0.0.0-20250804200300-7bf1ce1c7347 h1:3JhDl+JysaO8nh github.com/Necoro/html2text v0.0.0-20250804200300-7bf1ce1c7347/go.mod h1:2ErI0aycD43Ufr6CFK5lT/NrHGmoZuVbn1nlPThw69o= github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= -github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= -github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= +github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw= +github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ= github.com/RoaringBitmap/roaring v0.4.23/go.mod h1:D0gp8kJQgE1A4LQ5wFLggQEyvDi06Mq5mKs52e1TwOo= github.com/RoaringBitmap/roaring v0.7.1/go.mod h1:jdT9ykXwHFNdJbEtxePexlFYH9LXucApeS0/+/g+p1I= github.com/RoaringBitmap/roaring/v2 v2.10.0 h1:HbJ8Cs71lfCJyvmSptxeMX2PtvOC8yonlU0GQcy2Ak0= github.com/RoaringBitmap/roaring/v2 v2.10.0/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0= -github.com/STARRY-S/zip v0.2.1 h1:pWBd4tuSGm3wtpoqRZZ2EAwOmcHK6XFf7bU9qcJXyFg= -github.com/STARRY-S/zip v0.2.1/go.mod h1:xNvshLODWtC4EJ702g7cTYn13G53o1+X9BWnPFpcWV4= +github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4= +github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk= github.com/SaveTheRbtz/zstd-seekable-format-go/pkg v0.8.0 h1:tgjwQrDH5m6jIYB7kac5IQZmfUzQNseac/e3H4VoCNE= github.com/SaveTheRbtz/zstd-seekable-format-go/pkg v0.8.0/go.mod h1:1HmmMEVsr+0R1QWahSeMJkjSkq6CYAZu1aIbYSpfJ4o= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs= -github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= -github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= +github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= -github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= -github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= -github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= -github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= +github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= @@ -116,18 +110,18 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuW github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= -github.com/aws/aws-sdk-go-v2 v1.38.3 h1:B6cV4oxnMs45fql4yRH+/Po/YU+597zgWqvDpYMturk= -github.com/aws/aws-sdk-go-v2 v1.38.3/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= -github.com/aws/aws-sdk-go-v2/credentials v1.18.10 h1:xdJnXCouCx8Y0NncgoptztUocIYLKeQxrCgN6x9sdhg= -github.com/aws/aws-sdk-go-v2/credentials v1.18.10/go.mod h1:7tQk08ntj914F/5i9jC4+2HQTAuJirq7m1vZVIhEkWs= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 h1:uF68eJA6+S9iVr9WgX1NaRGyQ/6MdIyc4JNUo6TN1FA= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6/go.mod h1:qlPeVZCGPiobx8wb1ft0GHT5l+dc6ldnwInDFaMvC7Y= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 h1:pa1DEC6JoI0zduhZePp3zmhWvk/xxm4NB8Hy/Tlsgos= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6/go.mod h1:gxEjPebnhWGJoaDdtDkA0JX46VRg1wcTHYe63OfX5pE= -github.com/aws/aws-sdk-go-v2/service/codecommit v1.32.2 h1:qIySgaSYDLcInLpY0e7HPCi+AVeD/LTsl9EL1b692oA= -github.com/aws/aws-sdk-go-v2/service/codecommit v1.32.2/go.mod h1:SobWM1535Mn1WuThoIVLiLa/C1rRbxbbq5PZW2QFCIM= -github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE= -github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= +github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= +github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM= +github.com/aws/aws-sdk-go-v2/service/codecommit v1.33.8 h1:KxKGfYvkVOe/U/Z4yAd0ZySRJHavuL31VOC+fn7WEAs= +github.com/aws/aws-sdk-go-v2/service/codecommit v1.33.8/go.mod h1:cznnFD3BzYY+NB+4WoQ7SxdTACOsMqGCbQ5QaByPz4w= +github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= +github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -140,15 +134,15 @@ github.com/bits-and-blooms/bitset v1.24.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6 github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb h1:m935MPodAbYS46DG4pJSv7WO+VECIWUQ7OJYSoTrMh4= github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb/go.mod h1:PkYb9DJNAwrSvRx5DYA+gUcOIgTGVMNkfSCbZM8cWpI= github.com/blevesearch/bleve/v2 v2.0.5/go.mod h1:ZjWibgnbRX33c+vBRgla9QhPb4QOjD6fdVJ+R1Bk8LM= -github.com/blevesearch/bleve/v2 v2.5.3 h1:9l1xtKaETv64SZc1jc4Sy0N804laSa/LeMbYddq1YEM= -github.com/blevesearch/bleve/v2 v2.5.3/go.mod h1:Z/e8aWjiq8HeX+nW8qROSxiE0830yQA071dwR3yoMzw= +github.com/blevesearch/bleve/v2 v2.5.7 h1:2d9YrL5zrX5EBBW++GOaEKjE+NPWeZGaX77IM26m1Z8= +github.com/blevesearch/bleve/v2 v2.5.7/go.mod h1:yj0NlS7ocGC4VOSAedqDDMktdh2935v2CSWOCDMHdSA= github.com/blevesearch/bleve_index_api v1.0.0/go.mod h1:fiwKS0xLEm+gBRgv5mumf0dhgFr2mDgZah1pqv1c1M4= -github.com/blevesearch/bleve_index_api v1.2.9 h1:WqD3kvYwnlYLv8sTdH+AF7n/L4v969Cek68+wZnYj4Q= -github.com/blevesearch/bleve_index_api v1.2.9/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0= +github.com/blevesearch/bleve_index_api v1.2.11 h1:bXQ54kVuwP8hdrXUSOnvTQfgK0KI1+f9A0ITJT8tX1s= +github.com/blevesearch/bleve_index_api v1.2.11/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0= github.com/blevesearch/geo v0.2.4 h1:ECIGQhw+QALCZaDcogRTNSJYQXRtC8/m8IKiA706cqk= github.com/blevesearch/geo v0.2.4/go.mod h1:K56Q33AzXt2YExVHGObtmRSFYZKYGv0JEN5mdacJJR8= -github.com/blevesearch/go-faiss v1.0.25 h1:lel1rkOUGbT1CJ0YgzKwC7k+XH0XVBHnCVWahdCXk4U= -github.com/blevesearch/go-faiss v1.0.25/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk= +github.com/blevesearch/go-faiss v1.0.26 h1:4dRLolFgjPyjkaXwff4NfbZFdE/dfywbzDqporeQvXI= +github.com/blevesearch/go-faiss v1.0.26/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk= github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo= github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M= github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y= @@ -157,8 +151,8 @@ github.com/blevesearch/mmap-go v1.0.2/go.mod h1:ol2qBqYaOUsGdm7aRMRrYGgPvnwLe6Y+ github.com/blevesearch/mmap-go v1.0.4 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCDPWmc= github.com/blevesearch/mmap-go v1.0.4/go.mod h1:EWmEAOmdAS9z/pi/+Toxu99DnsbhG1TIxUoRmJw/pSs= github.com/blevesearch/scorch_segment_api/v2 v2.0.1/go.mod h1:lq7yK2jQy1yQjtjTfU931aVqz7pYxEudHaDwOt1tXfU= -github.com/blevesearch/scorch_segment_api/v2 v2.3.11 h1:bYuEgsyGqgU/gy0/Vk6g1eCUqGBs2r+3bRCv+Cnq2kc= -github.com/blevesearch/scorch_segment_api/v2 v2.3.11/go.mod h1:aAWoeQ3DdoZ3Z5138jXVSd1T/klGwvg11z0pSxrJSEk= +github.com/blevesearch/scorch_segment_api/v2 v2.3.13 h1:ZPjv/4VwWvHJZKeMSgScCapOy8+DdmsmRyLmSB88UoY= +github.com/blevesearch/scorch_segment_api/v2 v2.3.13/go.mod h1:ENk2LClTehOuMS8XzN3UxBEErYmtwkE7MAArFTXs9Vc= github.com/blevesearch/segment v0.9.0/go.mod h1:9PfHYUdQCgHktBgvtUOF4x+pc4/l8rdH0u5spnW85UQ= github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU= github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw= @@ -186,15 +180,15 @@ github.com/blevesearch/zapx/v14 v14.4.2/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXm github.com/blevesearch/zapx/v15 v15.2.0/go.mod h1:MmQceLpWfME4n1WrBFIwplhWmaQbQqLQARpaKUEOs/A= github.com/blevesearch/zapx/v15 v15.4.2 h1:sWxpDE0QQOTjyxYbAVjt3+0ieu8NCE0fDRaFxEsp31k= github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw= -github.com/blevesearch/zapx/v16 v16.2.4 h1:tGgfvleXTAkwsD5mEzgM3zCS/7pgocTCnO1oyAUjlww= -github.com/blevesearch/zapx/v16 v16.2.4/go.mod h1:Rti/REtuuMmzwsI8/C/qIzRaEoSK/wiFYw5e5ctUKKs= +github.com/blevesearch/zapx/v16 v16.2.8 h1:SlnzF0YGtSlrsOE3oE7EgEX6BIepGpeqxs1IjMbHLQI= +github.com/blevesearch/zapx/v16 v16.2.8/go.mod h1:murSoCJPCk25MqURrcJaBQ1RekuqSCSfMjXH4rHyA14= github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bmizerany/perks v0.0.0-20141205001514-d9a9656a3a4b/go.mod h1:ac9efd0D1fsDb3EJvhqgXRbFx7bs2wqZ10HQPeU8U/Q= github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU= github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs= -github.com/bodgit/sevenzip v1.6.0 h1:a4R0Wu6/P1o1pP/3VV++aEOcyeBxeO/xE2Y9NSTrr6A= -github.com/bodgit/sevenzip v1.6.0/go.mod h1:zOBh9nJUof7tcrlqJFv1koWRrhz3LbDbUNngkuZxLMc= +github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4= +github.com/bodgit/sevenzip v1.6.1/go.mod h1:GVoYQbEVbOGT8n2pfqCIMRUaRjQ8F9oSqoBEqZh5fQ8= github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4= github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM= github.com/bohde/codel v0.2.0 h1:fzF7ibgKmCfQbOzQCblmQcwzDRmV7WO7VMLm/hDvD3E= @@ -210,15 +204,17 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/buildkite/terminal-to-html/v3 v3.16.8 h1:QN/daUob6cmK8GcdKnwn9+YTlPr1vNj+oeAIiJK6fPc= github.com/buildkite/terminal-to-html/v3 v3.16.8/go.mod h1:+k1KVKROZocrTLsEQ9PEf9A+8+X8uaVV5iO1ZIOwKYM= -github.com/caddyserver/certmagic v0.24.0 h1:EfXTWpxHAUKgDfOj6MHImJN8Jm4AMFfMT6ITuKhrDF0= -github.com/caddyserver/certmagic v0.24.0/go.mod h1:xPT7dC1DuHHnS2yuEQCEyks+b89sUkMENh8dJF+InLE= -github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA= -github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= +github.com/caddyserver/certmagic v0.25.1 h1:4sIKKbOt5pg6+sL7tEwymE1x2bj6CHr80da1CRRIPbY= +github.com/caddyserver/certmagic v0.25.1/go.mod h1:VhyvndxtVton/Fo/wKhRoC46Rbw1fmjvQ3GjHYSQTEY= +github.com/caddyserver/zerossl v0.1.4 h1:CVJOE3MZeFisCERZjkxIcsqIH4fnFdlYWnPYeFtBHRw= +github.com/caddyserver/zerossl v0.1.4/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI= github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20251013092601-6327009efd21 h1:2d64+4Jek9vjYwhY93AjbleiVH+AeWvPwPmDi1mfKFQ= +github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20251013092601-6327009efd21/go.mod h1:fNlYtCHWTRC8MofQERZkVUNUWaOvZeTBqHn/amSbKZI= github.com/chi-middleware/proxy v1.1.1 h1:4HaXUp8o2+bhHr1OhVy+VjN0+L7/07JDcn6v7YrTjrQ= github.com/chi-middleware/proxy v1.1.1/go.mod h1:jQwMEJct2tz9VmtCELxvnXoMfa+SOdikvbVJVHv/M+0= github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= @@ -231,8 +227,8 @@ github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObk github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= -github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= @@ -260,11 +256,6 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dimiro1/reply v0.0.0-20200315094148-d0136a4c9e21 h1:PdsjTl0Cg+ZJgOx/CFV5NNgO1ThTreqdgKYiDCMHJwA= github.com/dimiro1/reply v0.0.0-20200315094148-d0136a4c9e21/go.mod h1:xJvkyD6Y2rZapGvPJLYo9dyx1s5dxBEDPa8T3YTuOk0= -github.com/djherbis/buffer v1.1.0/go.mod h1:VwN8VdFkMY0DCALdY8o00d3IZ6Amz/UNVMWcSaJT44o= -github.com/djherbis/buffer v1.2.0 h1:PH5Dd2ss0C7CRRhQCZ2u7MssF+No9ide8Ye71nPHcrQ= -github.com/djherbis/buffer v1.2.0/go.mod h1:fjnebbZjCUpPinBRD+TDwXSOeNQ7fPQWLfGQqiAiUyE= -github.com/djherbis/nio/v3 v3.0.1 h1:6wxhnuppteMa6RHA4L81Dq7ThkZH8SwnDzXDYy95vB4= -github.com/djherbis/nio/v3 v3.0.1/go.mod h1:Ng4h80pbZFMla1yKzm61cF0tqqilXZYrogmWgZxOcmg= github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= @@ -276,8 +267,8 @@ github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdf github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dvyukov/go-fuzz v0.0.0-20210429054444-fca39067bc72/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw= -github.com/editorconfig/editorconfig-core-go/v2 v2.6.3 h1:XVUp6qW3BIkmM3/1EkrHpa6bL56APOynfXcZEmIgOhs= -github.com/editorconfig/editorconfig-core-go/v2 v2.6.3/go.mod h1:ThHVc+hqbUsmE1wmK/MASpQEhCleWu1JDJDNhUOMy0c= +github.com/editorconfig/editorconfig-core-go/v2 v2.6.4 h1:CHwUbBVVyKWRX9kt5A/OtwhYUJB32DrFp9xzmjR6cac= +github.com/editorconfig/editorconfig-core-go/v2 v2.6.4/go.mod h1:JWRVKHdVW+dkv6F8p+xGCa6a+TyMrqsFbFkSs/aQkrQ= github.com/elazarl/go-bindata-assetfs v1.0.1/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= @@ -312,49 +303,43 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24= -github.com/go-ap/activitypub v0.0.0-20250810115208-cb73b20a1742 h1:X+SsQlZSgJO0A4d1+nI7+g4axZ8u3iUKPirYb5nB5ic= -github.com/go-ap/activitypub v0.0.0-20250810115208-cb73b20a1742/go.mod h1:0rgUaERG5qjYenwz4oN5OnUjvkdRuHRjb+2c8FRjz+w= -github.com/go-ap/errors v0.0.0-20250527110557-c8db454e53fd h1:fM5mNIWTPoxoOYoTLd6ifkKXSlXa830l5MYXsrt1UmE= -github.com/go-ap/errors v0.0.0-20250527110557-c8db454e53fd/go.mod h1:Vkh+Z3f24K8nMsJKXo1FHn5ebPsXvB/WDH5JRtYqdNo= -github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73 h1:GMKIYXyXPGIp+hYiWOhfqK4A023HdgisDT4YGgf99mw= -github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73/go.mod h1:jyveZeGw5LaADntW+UEsMjl3IlIwk+DxlYNsbofQkGA= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-chi/chi/v5 v5.0.1/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= -github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= -github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-co-op/gocron v1.37.0 h1:ZYDJGtQ4OMhTLKOKMIch+/CY70Brbb1dGdooLEhh7b0= github.com/go-co-op/gocron v1.37.0/go.mod h1:3L/n6BkO7ABj+TrfSVXLRzsP26zmikL4ISkLQ0O8iNY= -github.com/go-enry/go-enry/v2 v2.9.2 h1:giOQAtCgBX08kosrX818DCQJTCNtKwoPBGu0qb6nKTY= -github.com/go-enry/go-enry/v2 v2.9.2/go.mod h1:9yrj4ES1YrbNb1Wb7/PWYr2bpaCXUGRt0uafN0ISyG8= +github.com/go-enry/go-enry/v2 v2.9.4 h1:DS4l06/NgMzYjsJ2J52wORo6UsfFDjDCwfAn7w3gG44= +github.com/go-enry/go-enry/v2 v2.9.4/go.mod h1:9yrj4ES1YrbNb1Wb7/PWYr2bpaCXUGRt0uafN0ISyG8= github.com/go-enry/go-oniguruma v1.2.1 h1:k8aAMuJfMrqm/56SG2lV9Cfti6tC4x8673aHCcBk+eo= github.com/go-enry/go-oniguruma v1.2.1/go.mod h1:bWDhYP+S6xZQgiRL7wlTScFYBe023B6ilRZbCAD5Hf4= github.com/go-fed/httpsig v1.1.1-0.20201223112313-55836744818e h1:oRq/fiirun5HqlEWMLIcDmLpIELlG4iGbd0s8iqgPi8= github.com/go-fed/httpsig v1.1.1-0.20201223112313-55836744818e/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= -github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= -github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM= +github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.16.2 h1:fT6ZIOjE5iEnkzKyxTHK1W4HGAsPhqEqiSAssSO77hM= -github.com/go-git/go-git/v5 v5.16.2/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= +github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s= +github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= -github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU= -github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM= +github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4= +github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo= github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg= github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= github.com/go-redis/redis/v7 v7.4.1 h1:PASvf36gyUpr2zdOUS/9Zqc80GbM+9BDyiJSJDDOrTI= github.com/go-redis/redis/v7 v7.4.1/go.mod h1:JDNMw23GTyLNC4GZu9njt15ctBQVn7xjRfnwdHj/Dcg= github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= -github.com/go-redsync/redsync/v4 v4.13.0 h1:49X6GJfnbLGaIpBBREM/zA4uIMDXKAh1NDkvQ1EkZKA= -github.com/go-redsync/redsync/v4 v4.13.0/go.mod h1:HMW4Q224GZQz6x1Xc7040Yfgacukdzu7ifTDAKiyErQ= +github.com/go-redsync/redsync/v4 v4.15.0 h1:KH/XymuxSV7vyKs6z1Cxxj+N+N18JlPxgXeP6x4JY54= +github.com/go-redsync/redsync/v4 v4.15.0/go.mod h1:qNp+lLs3vkfZbtA/aM/OjlZHfEr5YTAYhRktFPKHC7s= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= @@ -373,10 +358,8 @@ github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7w github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14= github.com/gogs/go-gogs-client v0.0.0-20210131175652-1d7215cd8d85 h1:UjoPNDAQ5JPCjlxoJd6K8ALZqSDDhk2ymieAZOVaDg0= github.com/gogs/go-gogs-client v0.0.0-20210131175652-1d7215cd8d85/go.mod h1:fR6z1Ie6rtF7kl/vBYMfgD5/G5B1blui7z426/sj2DU= -github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= -github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= @@ -407,8 +390,8 @@ github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8l github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws= -github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE= +github.com/gomodule/redigo v1.9.3 h1:dNPSXeXv6HCq2jdyWfjgmhBdqnR6PRO3m/G05nvpPC8= +github.com/gomodule/redigo v1.9.3/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= @@ -441,8 +424,8 @@ github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OI github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= -github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY= -github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= +github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef h1:xpF9fUHpoIrrjX24DURVKiwHcFpw19ndIs+FwTSMbno= +github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -481,6 +464,8 @@ github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVU github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= +github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= @@ -525,12 +510,14 @@ github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PW github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= +github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/kljensen/snowball v0.6.0/go.mod h1:27N7E8fVU5H68RlUmnWwZCfxgt4POBJfENGMvNRhldw= @@ -546,8 +533,8 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.11.1 h1:wuChtj2hfsGmmx3nf1m7xC2XpK6OtelS2shMY+bGMtI= +github.com/lib/pq v1.11.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= @@ -566,30 +553,30 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= -github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= -github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/meilisearch/meilisearch-go v0.33.2 h1:YgsQSLYhAkRN2ias6I1KNRTjdYCN5w2uHbLUQ+xgrws= -github.com/meilisearch/meilisearch-go v0.33.2/go.mod h1:6eOPcQ+OAuwXvnONlfSgfgvr7TIAWM/6OdhcVHg8cF0= -github.com/mholt/acmez/v3 v3.1.2 h1:auob8J/0FhmdClQicvJvuDavgd5ezwLBfKuYmynhYzc= -github.com/mholt/acmez/v3 v3.1.2/go.mod h1:L1wOU06KKvq7tswuMDwKdcHeKpFFgkppZy/y0DFxagQ= -github.com/mholt/archives v0.1.3 h1:aEAaOtNra78G+TvV5ohmXrJOAzf++dIlYeDW3N9q458= -github.com/mholt/archives v0.1.3/go.mod h1:LUCGp++/IbV/I0Xq4SzcIR6uwgeh2yjnQWamjRQfLTU= +github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= +github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/meilisearch/meilisearch-go v0.36.0 h1:N1etykTektXt5KPcSbhBO0d5Xx5NaKj4pJWEM7WA5dI= +github.com/meilisearch/meilisearch-go v0.36.0/go.mod h1:HBfHzKMxcSbTOvqdfuRA/yf6Vk9IivcwKocWRuW7W78= +github.com/mholt/acmez/v3 v3.1.4 h1:DyzZe/RnAzT3rpZj/2Ii5xZpiEvvYk3cQEN/RmqxwFQ= +github.com/mholt/acmez/v3 v3.1.4/go.mod h1:L1wOU06KKvq7tswuMDwKdcHeKpFFgkppZy/y0DFxagQ= +github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ= +github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= -github.com/microsoft/go-mssqldb v1.9.3 h1:hy4p+LDC8LIGvI3JATnLVmBOLMJbmn5X400mr5j0lPs= -github.com/microsoft/go-mssqldb v1.9.3/go.mod h1:GBbW9ASTiDC+mpgWDGKdm3FnFLTUsLYN3iFL90lQ+PA= -github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= -github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= +github.com/microsoft/go-mssqldb v1.9.6 h1:1MNQg5UiSsokiPz3++K2KPx4moKrwIqly1wv+RyCKTw= +github.com/microsoft/go-mssqldb v1.9.6/go.mod h1:yYMPDufyoF2vVuVCUGtZARr06DKFIhMrluTcgWlXpr4= +github.com/miekg/dns v1.1.69 h1:Kb7Y/1Jo+SG+a2GtfoFUfDkG//csdRPwRLkCsxDG9Sc= +github.com/miekg/dns v1.1.69/go.mod h1:7OyjD9nEba5OkqQ/hB4fy3PIoxafSZJtducccIelz3g= github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0= github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc= github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= -github.com/minio/minio-go/v7 v7.0.95 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYEvYU= -github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo= -github.com/minio/minlz v1.0.0 h1:Kj7aJZ1//LlTP1DM8Jm7lNKvvJS2m74gyyXXn3+uJWQ= -github.com/minio/minlz v1.0.0/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= +github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0= +github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM= +github.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A= +github.com/minio/minlz v1.0.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -608,10 +595,12 @@ github.com/msteinert/pam v1.2.0 h1:mYfjlvN2KYs2Pb9G6nb/1f/nPfAttT/Jee5Sq9r3bGE= github.com/msteinert/pam v1.2.0/go.mod h1:d2n0DCUK8rGecChV3JzvmsDjOY4R7AYbsNxAT+ftQl0= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/niklasfasching/go-org v1.9.1 h1:/3s4uTPOF06pImGa2Yvlp24yKXZoTYM+nsIlMzfpg/0= github.com/niklasfasching/go-org v1.9.1/go.mod h1:ZAGFFkWvUQcpazmi/8nHqwvARpr1xpb+Es67oUGX/48= -github.com/nwaples/rardecode/v2 v2.1.0 h1:JQl9ZoBPDy+nIZGb1mx8+anfHp/LV3NE2MjMiv0ct/U= -github.com/nwaples/rardecode/v2 v2.1.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= +github.com/nwaples/rardecode/v2 v2.2.0 h1:4ufPGHiNe1rYJxYfehALLjup4Ls3ck42CWwjKiOqu0A= +github.com/nwaples/rardecode/v2 v2.2.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= @@ -658,24 +647,26 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= -github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= -github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= -github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/quasoft/websspi v1.1.2 h1:/mA4w0LxWlE3novvsoEL6BBA1WnjJATbjkh1kFrTidw= github.com/quasoft/websspi v1.1.2/go.mod h1:HmVdl939dQ0WIXZhyik+ARdI03M6bQzaSEKcgpFmewk= github.com/rcrowley/go-metrics v0.0.0-20190826022208-cac0b30c2563/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= -github.com/redis/go-redis/v9 v9.12.1 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgvovg= -github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= -github.com/redis/rueidis v1.0.19 h1:s65oWtotzlIFN8eMPhyYwxlwLR1lUdhza2KtWprKYSo= -github.com/redis/rueidis v1.0.19/go.mod h1:8B+r5wdnjwK3lTFml5VtxjzGOQAC+5UmujoD12pDrEo= -github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk= -github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4= +github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= +github.com/redis/rueidis v1.0.69 h1:WlUefRhuDekji5LsD387ys3UCJtSFeBVf0e5yI0B8b4= +github.com/redis/rueidis v1.0.69/go.mod h1:Lkhr2QTgcoYBhxARU7kJRO8SyVlgUuEkcJO1Y8MCluA= +github.com/redis/rueidis/rueidiscompat v1.0.69 h1:IWVYY9lXdjNO3do2VpJT7aDFi8zbCUuQxZB6E2Grahs= +github.com/redis/rueidis/rueidiscompat v1.0.69/go.mod h1:iC4Y8DoN0Uth0Uezg9e2trvNRC7QAgGeuP2OPLb5ccI= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rhysd/actionlint v1.7.7 h1:0KgkoNTrYY7vmOCs9BW2AHxLvvpoY9nEUzgBHiPUr0k= github.com/rhysd/actionlint v1.7.7/go.mod h1:AE6I6vJEkNaIfWqC2GNE5spIJNhxf8NCtLEKU4NnUXg= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -703,6 +694,8 @@ github.com/serenize/snaker v0.0.0-20171204205717-a683aaf2d516/go.mod h1:Yow6lPLS github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= @@ -714,9 +707,11 @@ github.com/smartystreets/assertions v1.1.1/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYl github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= github.com/smartystreets/goconvey v0.0.0-20190731233626-505e41936337 h1:WN9BUFbdyOsSH/XohnWpXOlq9NBD5sGAB2FciQMUEe8= github.com/smartystreets/goconvey v0.0.0-20190731233626-505e41936337/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= -github.com/sorairolake/lzip-go v0.3.5 h1:ms5Xri9o1JBIWvOFAorYtUNik6HI3HgBTkISiqu0Cwg= -github.com/sorairolake/lzip-go v0.3.5/go.mod h1:N0KYq5iWrMXI0ZEXKXaS9hCyOjZUQdBDEIbXfoUwbdk= +github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik= +github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= @@ -729,6 +724,7 @@ github.com/steveyen/gtreap v0.1.0/go.mod h1:kl/5J7XbrOmlIbYIXdRHDDE5QxHqpk0cmkT7 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= @@ -748,8 +744,8 @@ github.com/stvp/tempredis v0.0.0-20181119212430-b82af8480203/go.mod h1:oqN97ltKN github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE= github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= -github.com/tinylib/msgp v1.4.0 h1:SYOeDRiydzOw9kSiwdYp9UcBgPFtLU2WDHaJXyHruf8= -github.com/tinylib/msgp v1.4.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o= +github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= +github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/tstranex/u2f v1.0.0 h1:HhJkSzDDlVSVIVt7pDJwCHQj67k7A5EeBgPmeD+pVsQ= github.com/tstranex/u2f v1.0.0/go.mod h1:eahSLaqAS0zsIEv80+vXT7WanXs7MQQDg3j3wGBSayo= github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= @@ -762,11 +758,9 @@ github.com/urfave/cli-docs/v3 v3.0.0-alpha6 h1:w/l/N0xw1rO/aHRIGXJ0lDwwYFOzilup1 github.com/urfave/cli-docs/v3 v3.0.0-alpha6/go.mod h1:p7Z4lg8FSTrPB9GTaNyTrK3ygffHZcK3w0cU2VE+mzU= github.com/urfave/cli/v3 v3.4.1 h1:1M9UOCy5bLmGnuu1yn3t3CB4rG79Rtoxuv1sPhnm6qM= github.com/urfave/cli/v3 v3.4.1/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= -github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ= -github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= -github.com/wneessen/go-mail v0.6.2 h1:c6V7c8D2mz868z9WJ+8zDKtUyLfZ1++uAZmo2GRFji8= -github.com/wneessen/go-mail v0.6.2/go.mod h1:L/PYjPK3/2ZlNb2/FjEBIn9n1rUWjW+Toy531oVmeb4= +github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8= +github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= @@ -789,8 +783,8 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= -github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= +github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUeiOUc= @@ -817,10 +811,16 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= +go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc= go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -837,9 +837,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= -golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -848,12 +847,12 @@ golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= -golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= -golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= +golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0= +golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4= -golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c= +golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I= +golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -876,8 +875,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= -golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -906,15 +905,15 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -930,9 +929,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -974,9 +972,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -987,9 +984,8 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= -golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= -golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= -golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1003,9 +999,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= @@ -1041,8 +1036,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -1073,8 +1068,8 @@ google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvx google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 h1:pmJpJEvT846VzausCQ5d7KreSROcDqmO388w5YbnltA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda h1:i/Q+bfisr7gq6feoJnS/DlpdwEL4ihp41fvRiM3Ork0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -1082,16 +1077,16 @@ google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyac google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= -google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= -google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -1099,8 +1094,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k= +gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= @@ -1127,20 +1122,20 @@ modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw= modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0= modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw= modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY= -modernc.org/libc v1.22.2 h1:4U7v51GyhlWqQmwCHj28Rdq2Yzwk55ovjFrdPjs8Hb0= -modernc.org/libc v1.22.2/go.mod h1:uvQavJ1pZ0hIoC/jfqNoMLURIMhKzINIWypNM17puug= -modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= -modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= -modernc.org/memory v1.4.0 h1:crykUfNSnMAXaOJnnxcSzbUGMqkLWjklJKkBK2nwZwk= -modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= modernc.org/sqlite v1.20.4 h1:J8+m2trkN+KKoE7jglyHYYYiaq5xmz2HoHJIiBlRzbE= modernc.org/sqlite v1.20.4/go.mod h1:zKcGyrICaxNTMEHSr1HQ2GUraP0j+845GYw37+EyT6A= -modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY= -modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= -modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg= -modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI= mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk= pgregory.net/rapid v0.4.2 h1:lsi9jhvZTYvzVpeG93WWgimPRmiJQfGFRNTEZh1dtY0= @@ -1152,5 +1147,5 @@ strk.kbt.io/projects/go/libravatar v0.0.0-20191008002943-06d1c002b251 h1:mUcz5b3 strk.kbt.io/projects/go/libravatar v0.0.0-20191008002943-06d1c002b251/go.mod h1:FJGmPh3vz9jSos1L/F91iAgnC/aejc0wIIrF2ZwJxdY= xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo= xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE= -xorm.io/xorm v1.3.10 h1:yR83hTT4mKIPyA/lvWFTzS35xjLwkiYnwdw0Qupeh0o= -xorm.io/xorm v1.3.10/go.mod h1:Lo7hmsFF0F0GbDE7ubX5ZKa+eCf0eCuiJAUG3oI5cxQ= +xorm.io/xorm v1.3.11 h1:i4tlVUASogb0ZZFJHA7dZqoRU2pUpUsutnNdaOlFyMI= +xorm.io/xorm v1.3.11/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q= diff --git a/main.go b/main.go index 2c25bac4e3d..fcfbb733715 100644 --- a/main.go +++ b/main.go @@ -26,9 +26,8 @@ import ( // these flags will be set by the build flags var ( - Version = "development" // program version for this build - Tags = "" // the Golang build tags - MakeVersion = "" // "make" program version if built with make + Version = "development" // program version for this build + Tags = "" // the Golang build tags ) func init() { @@ -44,14 +43,12 @@ func main() { } app := cmd.NewMainApp(cmd.AppVersion{Version: Version, Extra: formatBuiltWith()}) _ = cmd.RunMainApp(app, os.Args...) // all errors should have been handled by the RunMainApp + // flush the queued logs before exiting, it is a MUST, otherwise there will be log loss log.GetManager().Close() } func formatBuiltWith() string { version := runtime.Version() - if len(MakeVersion) > 0 { - version = MakeVersion + ", " + runtime.Version() - } if len(Tags) == 0 { return " built with " + version } diff --git a/models/actions/main_test.go b/models/actions/main_test.go index 5d5089e3bba..4af483813ab 100644 --- a/models/actions/main_test.go +++ b/models/actions/main_test.go @@ -13,6 +13,8 @@ func TestMain(m *testing.M) { unittest.MainTest(m, &unittest.TestOptions{ FixtureFiles: []string{ "action_runner_token.yml", + "action_run.yml", + "repository.yml", }, }) } diff --git a/models/actions/run.go b/models/actions/run.go index f5ccba06c22..e293a6056fb 100644 --- a/models/actions/run.go +++ b/models/actions/run.go @@ -16,13 +16,13 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" webhook_module "code.gitea.io/gitea/modules/webhook" - "github.com/nektos/act/pkg/jobparser" "xorm.io/builder" ) @@ -30,7 +30,7 @@ import ( type ActionRun struct { ID int64 Title string - RepoID int64 `xorm:"index unique(repo_index)"` + RepoID int64 `xorm:"unique(repo_index) index(repo_concurrency)"` Repo *repo_model.Repository `xorm:"-"` OwnerID int64 `xorm:"index"` WorkflowID string `xorm:"index"` // the name of workflow file @@ -49,6 +49,9 @@ type ActionRun struct { TriggerEvent string // the trigger event defined in the `on` configuration of the triggered workflow Status Status `xorm:"index"` Version int `xorm:"version default 0"` // Status could be updated concomitantly, so an optimistic lock is needed + RawConcurrency string // raw concurrency + ConcurrencyGroup string `xorm:"index(repo_concurrency) NOT NULL DEFAULT ''"` + ConcurrencyCancel bool `xorm:"NOT NULL DEFAULT FALSE"` // Started and Stopped is used for recording last run time, if rerun happened, they will be reset to 0 Started timeutil.TimeStamp Stopped timeutil.TimeStamp @@ -67,14 +70,14 @@ func (run *ActionRun) HTMLURL() string { if run.Repo == nil { return "" } - return fmt.Sprintf("%s/actions/runs/%d", run.Repo.HTMLURL(), run.Index) + return fmt.Sprintf("%s/actions/runs/%d", run.Repo.HTMLURL(), run.ID) } func (run *ActionRun) Link() string { if run.Repo == nil { return "" } - return fmt.Sprintf("%s/actions/runs/%d", run.Repo.Link(), run.Index) + return fmt.Sprintf("%s/actions/runs/%d", run.Repo.Link(), run.ID) } func (run *ActionRun) WorkflowLink() string { @@ -102,6 +105,15 @@ func (run *ActionRun) PrettyRef() string { return refName.ShortName() } +// RefTooltip return a tooltop of run's ref. For pull request, it's the title of the PR, otherwise it's the ShortName. +func (run *ActionRun) RefTooltip() string { + payload, err := run.GetPullRequestEventPayload() + if err == nil && payload != nil && payload.PullRequest != nil { + return payload.PullRequest.Title + } + return git.RefName(run.Ref).ShortName() +} + // LoadAttributes load Repo TriggerUser if not loaded func (run *ActionRun) LoadAttributes(ctx context.Context) error { if run == nil { @@ -156,7 +168,7 @@ func (run *ActionRun) GetPushEventPayload() (*api.PushPayload, error) { } func (run *ActionRun) GetPullRequestEventPayload() (*api.PullRequestPayload, error) { - if run.Event.IsPullRequest() { + if run.Event.IsPullRequest() || run.Event.IsPullRequestReview() { var payload api.PullRequestPayload if err := json.Unmarshal([]byte(run.EventPayload), &payload); err != nil { return nil, err @@ -181,9 +193,11 @@ func (run *ActionRun) IsSchedule() bool { return run.ScheduleID > 0 } -func updateRepoRunsNumbers(ctx context.Context, repo *repo_model.Repository) error { +// UpdateRepoRunsNumbers updates the number of runs and closed runs of a repository. +func UpdateRepoRunsNumbers(ctx context.Context, repo *repo_model.Repository) error { _, err := db.GetEngine(ctx).ID(repo.ID). NoAutoTime(). + Cols("num_action_runs", "num_closed_action_runs"). SetExpr("num_action_runs", builder.Select("count(*)").From("action_run"). Where(builder.Eq{"repo_id": repo.ID}), @@ -238,116 +252,62 @@ func CancelPreviousJobs(ctx context.Context, repoID int64, ref, workflowID strin return cancelledJobs, err } - // Iterate over each job and attempt to cancel it. - for _, job := range jobs { - // Skip jobs that are already in a terminal state (completed, cancelled, etc.). - status := job.Status - if status.IsDone() { - continue - } - - // If the job has no associated task (probably an error), set its status to 'Cancelled' and stop it. - if job.TaskID == 0 { - job.Status = StatusCancelled - job.Stopped = timeutil.TimeStampNow() - - // Update the job's status and stopped time in the database. - n, err := UpdateRunJob(ctx, job, builder.Eq{"task_id": 0}, "status", "stopped") - if err != nil { - return cancelledJobs, err - } - - // If the update affected 0 rows, it means the job has changed in the meantime, so we need to try again. - if n == 0 { - return cancelledJobs, errors.New("job has changed, try again") - } - - cancelledJobs = append(cancelledJobs, job) - // Continue with the next job. - continue - } - - // If the job has an associated task, try to stop the task, effectively cancelling the job. - if err := StopTask(ctx, job.TaskID, StatusCancelled); err != nil { - return cancelledJobs, err - } - cancelledJobs = append(cancelledJobs, job) + cjs, err := CancelJobs(ctx, jobs) + if err != nil { + return cancelledJobs, err } + cancelledJobs = append(cancelledJobs, cjs...) } // Return nil to indicate successful cancellation of all running and waiting jobs. return cancelledJobs, nil } -// InsertRun inserts a run -// The title will be cut off at 255 characters if it's longer than 255 characters. -func InsertRun(ctx context.Context, run *ActionRun, jobs []*jobparser.SingleWorkflow) error { - return db.WithTx(ctx, func(ctx context.Context) error { - index, err := db.GetNextResourceIndex(ctx, "action_run_index", run.RepoID) - if err != nil { - return err - } - run.Index = index - run.Title = util.EllipsisDisplayString(run.Title, 255) - - if err := db.Insert(ctx, run); err != nil { - return err +func CancelJobs(ctx context.Context, jobs []*ActionRunJob) ([]*ActionRunJob, error) { + cancelledJobs := make([]*ActionRunJob, 0, len(jobs)) + // Iterate over each job and attempt to cancel it. + for _, job := range jobs { + // Skip jobs that are already in a terminal state (completed, cancelled, etc.). + status := job.Status + if status.IsDone() { + continue } - if run.Repo == nil { - repo, err := repo_model.GetRepositoryByID(ctx, run.RepoID) + // If the job has no associated task (probably an error), set its status to 'Cancelled' and stop it. + if job.TaskID == 0 { + job.Status = StatusCancelled + job.Stopped = timeutil.TimeStampNow() + + // Update the job's status and stopped time in the database. + n, err := UpdateRunJob(ctx, job, builder.Eq{"task_id": 0}, "status", "stopped") if err != nil { - return err + return cancelledJobs, err } - run.Repo = repo + + // If the update affected 0 rows, it means the job has changed in the meantime + if n == 0 { + log.Error("Failed to cancel job %d because it has changed", job.ID) + continue + } + + cancelledJobs = append(cancelledJobs, job) + // Continue with the next job. + continue } - if err := updateRepoRunsNumbers(ctx, run.Repo); err != nil { - return err + // If the job has an associated task, try to stop the task, effectively cancelling the job. + if err := StopTask(ctx, job.TaskID, StatusCancelled); err != nil { + return cancelledJobs, err } + updatedJob, err := GetRunJobByRunAndID(ctx, job.RunID, job.ID) + if err != nil { + return cancelledJobs, fmt.Errorf("get job: %w", err) + } + cancelledJobs = append(cancelledJobs, updatedJob) + } - runJobs := make([]*ActionRunJob, 0, len(jobs)) - var hasWaiting bool - for _, v := range jobs { - id, job := v.Job() - needs := job.Needs() - if err := v.SetJob(id, job.EraseNeeds()); err != nil { - return err - } - payload, _ := v.Marshal() - status := StatusWaiting - if len(needs) > 0 || run.NeedApproval { - status = StatusBlocked - } else { - hasWaiting = true - } - job.Name = util.EllipsisDisplayString(job.Name, 255) - runJobs = append(runJobs, &ActionRunJob{ - RunID: run.ID, - RepoID: run.RepoID, - OwnerID: run.OwnerID, - CommitSHA: run.CommitSHA, - IsForkPullRequest: run.IsForkPullRequest, - Name: job.Name, - WorkflowPayload: payload, - JobID: id, - Needs: needs, - RunsOn: job.RunsOn(), - Status: status, - }) - } - if err := db.Insert(ctx, runJobs); err != nil { - return err - } - - // if there is a job in the waiting status, increase tasks version. - if hasWaiting { - if err := IncreaseTaskVersion(ctx, run.OwnerID, run.RepoID); err != nil { - return err - } - } - return nil - }) + // Return nil to indicate successful cancellation of all running and waiting jobs. + return cancelledJobs, nil } func GetRunByRepoAndID(ctx context.Context, repoID, runID int64) (*ActionRun, error) { @@ -432,7 +392,7 @@ func UpdateRun(ctx context.Context, run *ActionRun, cols ...string) error { if err = run.LoadRepo(ctx); err != nil { return err } - if err := updateRepoRunsNumbers(ctx, run.Repo); err != nil { + if err := UpdateRepoRunsNumbers(ctx, run.Repo); err != nil { return err } } @@ -441,3 +401,59 @@ func UpdateRun(ctx context.Context, run *ActionRun, cols ...string) error { } type ActionRunIndex db.ResourceIndex + +func GetConcurrentRunsAndJobs(ctx context.Context, repoID int64, concurrencyGroup string, status []Status) ([]*ActionRun, []*ActionRunJob, error) { + runs, err := db.Find[ActionRun](ctx, &FindRunOptions{ + RepoID: repoID, + ConcurrencyGroup: concurrencyGroup, + Status: status, + }) + if err != nil { + return nil, nil, fmt.Errorf("find runs: %w", err) + } + + jobs, err := db.Find[ActionRunJob](ctx, &FindRunJobOptions{ + RepoID: repoID, + ConcurrencyGroup: concurrencyGroup, + Statuses: status, + }) + if err != nil { + return nil, nil, fmt.Errorf("find jobs: %w", err) + } + + return runs, jobs, nil +} + +func CancelPreviousJobsByRunConcurrency(ctx context.Context, actionRun *ActionRun) ([]*ActionRunJob, error) { + if actionRun.ConcurrencyGroup == "" { + return nil, nil + } + + var jobsToCancel []*ActionRunJob + + statusFindOption := []Status{StatusWaiting, StatusBlocked} + if actionRun.ConcurrencyCancel { + statusFindOption = append(statusFindOption, StatusRunning) + } + runs, jobs, err := GetConcurrentRunsAndJobs(ctx, actionRun.RepoID, actionRun.ConcurrencyGroup, statusFindOption) + if err != nil { + return nil, fmt.Errorf("find concurrent runs and jobs: %w", err) + } + jobsToCancel = append(jobsToCancel, jobs...) + + // cancel runs in the same concurrency group + for _, run := range runs { + if run.ID == actionRun.ID { + continue + } + jobs, err := db.Find[ActionRunJob](ctx, FindRunJobOptions{ + RunID: run.ID, + }) + if err != nil { + return nil, fmt.Errorf("find run %d jobs: %w", run.ID, err) + } + jobsToCancel = append(jobsToCancel, jobs...) + } + + return CancelJobs(ctx, jobsToCancel) +} diff --git a/models/actions/run_job.go b/models/actions/run_job.go index e7fa21270c1..c752e61b7d7 100644 --- a/models/actions/run_job.go +++ b/models/actions/run_job.go @@ -11,6 +11,7 @@ import ( "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/actions/jobparser" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" @@ -22,23 +23,38 @@ type ActionRunJob struct { ID int64 RunID int64 `xorm:"index"` Run *ActionRun `xorm:"-"` - RepoID int64 `xorm:"index"` + RepoID int64 `xorm:"index(repo_concurrency)"` Repo *repo_model.Repository `xorm:"-"` OwnerID int64 `xorm:"index"` CommitSHA string `xorm:"index"` IsForkPullRequest bool Name string `xorm:"VARCHAR(255)"` Attempt int64 - WorkflowPayload []byte - JobID string `xorm:"VARCHAR(255)"` // job id in workflow, not job's id - Needs []string `xorm:"JSON TEXT"` - RunsOn []string `xorm:"JSON TEXT"` - TaskID int64 // the latest task of the job - Status Status `xorm:"index"` - Started timeutil.TimeStamp - Stopped timeutil.TimeStamp - Created timeutil.TimeStamp `xorm:"created"` - Updated timeutil.TimeStamp `xorm:"updated index"` + + // WorkflowPayload is act/jobparser.SingleWorkflow for act/jobparser.Parse + // it should contain exactly one job with global workflow fields for this model + WorkflowPayload []byte + + JobID string `xorm:"VARCHAR(255)"` // job id in workflow, not job's id + Needs []string `xorm:"JSON TEXT"` + RunsOn []string `xorm:"JSON TEXT"` + TaskID int64 // the latest task of the job + Status Status `xorm:"index"` + + RawConcurrency string // raw concurrency from job YAML's "concurrency" section + + // IsConcurrencyEvaluated is only valid/needed when this job's RawConcurrency is not empty. + // If RawConcurrency can't be evaluated (e.g. depend on other job's outputs or have errors), this field will be false. + // If RawConcurrency has been successfully evaluated, this field will be true, ConcurrencyGroup and ConcurrencyCancel are also set. + IsConcurrencyEvaluated bool + + ConcurrencyGroup string `xorm:"index(repo_concurrency) NOT NULL DEFAULT ''"` // evaluated concurrency.group + ConcurrencyCancel bool `xorm:"NOT NULL DEFAULT FALSE"` // evaluated concurrency.cancel-in-progress + + Started timeutil.TimeStamp + Stopped timeutil.TimeStamp + Created timeutil.TimeStamp `xorm:"created"` + Updated timeutil.TimeStamp `xorm:"updated index"` } func init() { @@ -84,13 +100,43 @@ func (job *ActionRunJob) LoadAttributes(ctx context.Context) error { return job.Run.LoadAttributes(ctx) } -func GetRunJobByID(ctx context.Context, id int64) (*ActionRunJob, error) { +// ParseJob parses the job structure from the ActionRunJob.WorkflowPayload +func (job *ActionRunJob) ParseJob() (*jobparser.Job, error) { + // job.WorkflowPayload is a SingleWorkflow created from an ActionRun's workflow, which exactly contains this job's YAML definition. + // Ideally it shouldn't be called "Workflow", it is just a job with global workflow fields + trigger + parsedWorkflows, err := jobparser.Parse(job.WorkflowPayload) + if err != nil { + return nil, fmt.Errorf("job %d single workflow: unable to parse: %w", job.ID, err) + } else if len(parsedWorkflows) != 1 { + return nil, fmt.Errorf("job %d single workflow: not single workflow", job.ID) + } + _, workflowJob := parsedWorkflows[0].Job() + if workflowJob == nil { + // it shouldn't happen, and since the callers don't check nil, so return an error instead of nil + return nil, util.ErrorWrap(util.ErrNotExist, "job %d single workflow: payload doesn't contain a job", job.ID) + } + return workflowJob, nil +} + +func GetRunJobByRepoAndID(ctx context.Context, repoID, jobID int64) (*ActionRunJob, error) { var job ActionRunJob - has, err := db.GetEngine(ctx).Where("id=?", id).Get(&job) + has, err := db.GetEngine(ctx).Where("id=? AND repo_id=?", jobID, repoID).Get(&job) if err != nil { return nil, err } else if !has { - return nil, fmt.Errorf("run job with id %d: %w", id, util.ErrNotExist) + return nil, fmt.Errorf("run job with id %d: %w", jobID, util.ErrNotExist) + } + + return &job, nil +} + +func GetRunJobByRunAndID(ctx context.Context, runID, jobID int64) (*ActionRunJob, error) { + var job ActionRunJob + has, err := db.GetEngine(ctx).Where("id=? AND run_id=?", jobID, runID).Get(&job) + if err != nil { + return nil, err + } else if !has { + return nil, fmt.Errorf("run job with id %d: %w", jobID, util.ErrNotExist) } return &job, nil @@ -125,7 +171,7 @@ func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, col return affected, nil } - if affected != 0 && slices.Contains(cols, "status") && job.Status.IsWaiting() { + if slices.Contains(cols, "status") && job.Status.IsWaiting() { // if the status of job changes to waiting again, increase tasks version. if err := IncreaseTaskVersion(ctx, job.OwnerID, job.RepoID); err != nil { return 0, err @@ -134,7 +180,7 @@ func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, col if job.RunID == 0 { var err error - if job, err = GetRunJobByID(ctx, job.ID); err != nil { + if job, err = GetRunJobByRepoAndID(ctx, job.RepoID, job.ID); err != nil { return 0, err } } @@ -197,3 +243,39 @@ func AggregateJobStatus(jobs []*ActionRunJob) Status { return StatusUnknown // it shouldn't happen } } + +func CancelPreviousJobsByJobConcurrency(ctx context.Context, job *ActionRunJob) (jobsToCancel []*ActionRunJob, _ error) { + if job.RawConcurrency == "" { + return nil, nil + } + if !job.IsConcurrencyEvaluated { + return nil, nil + } + if job.ConcurrencyGroup == "" { + return nil, nil + } + + statusFindOption := []Status{StatusWaiting, StatusBlocked} + if job.ConcurrencyCancel { + statusFindOption = append(statusFindOption, StatusRunning) + } + runs, jobs, err := GetConcurrentRunsAndJobs(ctx, job.RepoID, job.ConcurrencyGroup, statusFindOption) + if err != nil { + return nil, fmt.Errorf("find concurrent runs and jobs: %w", err) + } + jobs = slices.DeleteFunc(jobs, func(j *ActionRunJob) bool { return j.ID == job.ID }) + jobsToCancel = append(jobsToCancel, jobs...) + + // cancel runs in the same concurrency group + for _, run := range runs { + jobs, err := db.Find[ActionRunJob](ctx, FindRunJobOptions{ + RunID: run.ID, + }) + if err != nil { + return nil, fmt.Errorf("find run %d jobs: %w", run.ID, err) + } + jobsToCancel = append(jobsToCancel, jobs...) + } + + return CancelJobs(ctx, jobsToCancel) +} diff --git a/models/actions/run_job_list.go b/models/actions/run_job_list.go index 5f7bb62878a..10f76d3641b 100644 --- a/models/actions/run_job_list.go +++ b/models/actions/run_job_list.go @@ -69,12 +69,13 @@ func (jobs ActionJobList) LoadAttributes(ctx context.Context, withRepo bool) err type FindRunJobOptions struct { db.ListOptions - RunID int64 - RepoID int64 - OwnerID int64 - CommitSHA string - Statuses []Status - UpdatedBefore timeutil.TimeStamp + RunID int64 + RepoID int64 + OwnerID int64 + CommitSHA string + Statuses []Status + UpdatedBefore timeutil.TimeStamp + ConcurrencyGroup string } func (opts FindRunJobOptions) ToConds() builder.Cond { @@ -94,6 +95,12 @@ func (opts FindRunJobOptions) ToConds() builder.Cond { if opts.UpdatedBefore > 0 { cond = cond.And(builder.Lt{"`action_run_job`.updated": opts.UpdatedBefore}) } + if opts.ConcurrencyGroup != "" { + if opts.RepoID == 0 { + panic("Invalid FindRunJobOptions: repo_id is required") + } + cond = cond.And(builder.Eq{"`action_run_job`.concurrency_group": opts.ConcurrencyGroup}) + } return cond } diff --git a/models/actions/run_list.go b/models/actions/run_list.go index 12c55e538e7..2628c4712f5 100644 --- a/models/actions/run_list.go +++ b/models/actions/run_list.go @@ -64,15 +64,16 @@ func (runs RunList) LoadRepos(ctx context.Context) error { type FindRunOptions struct { db.ListOptions - RepoID int64 - OwnerID int64 - WorkflowID string - Ref string // the commit/tag/… that caused this workflow - TriggerUserID int64 - TriggerEvent webhook_module.HookEventType - Approved bool // not util.OptionalBool, it works only when it's true - Status []Status - CommitSHA string + RepoID int64 + OwnerID int64 + WorkflowID string + Ref string // the commit/tag/… that caused this workflow + TriggerUserID int64 + TriggerEvent webhook_module.HookEventType + Approved bool // not util.OptionalBool, it works only when it's true + Status []Status + ConcurrencyGroup string + CommitSHA string } func (opts FindRunOptions) ToConds() builder.Cond { @@ -101,6 +102,12 @@ func (opts FindRunOptions) ToConds() builder.Cond { if opts.CommitSHA != "" { cond = cond.And(builder.Eq{"`action_run`.commit_sha": opts.CommitSHA}) } + if len(opts.ConcurrencyGroup) > 0 { + if opts.RepoID == 0 { + panic("Invalid FindRunOptions: repo_id is required") + } + cond = cond.And(builder.Eq{"`action_run`.concurrency_group": opts.ConcurrencyGroup}) + } return cond } diff --git a/models/actions/run_test.go b/models/actions/run_test.go new file mode 100644 index 00000000000..bd2b92f4f66 --- /dev/null +++ b/models/actions/run_test.go @@ -0,0 +1,35 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + + "github.com/stretchr/testify/assert" +) + +func TestUpdateRepoRunsNumbers(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + // update the number to a wrong one, the original is 3 + _, err := db.GetEngine(t.Context()).ID(4).Cols("num_closed_action_runs").Update(&repo_model.Repository{ + NumClosedActionRuns: 2, + }) + assert.NoError(t, err) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + assert.Equal(t, 4, repo.NumActionRuns) + assert.Equal(t, 2, repo.NumClosedActionRuns) + + // now update will correct them, only num_actionr_runs and num_closed_action_runs should be updated + err = UpdateRepoRunsNumbers(t.Context(), repo) + assert.NoError(t, err) + repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + assert.Equal(t, 5, repo.NumActionRuns) + assert.Equal(t, 3, repo.NumClosedActionRuns) +} diff --git a/models/actions/runner.go b/models/actions/runner.go index 81d4249ae0b..f5d40ca7d66 100644 --- a/models/actions/runner.go +++ b/models/actions/runner.go @@ -14,6 +14,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/shared/types" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" @@ -61,6 +62,8 @@ type ActionRunner struct { AgentLabels []string `xorm:"TEXT"` // Store if this is a runner that only ever get one single job assigned Ephemeral bool `xorm:"ephemeral NOT NULL DEFAULT false"` + // Store if this runner is disabled and should not pick up new jobs + IsDisabled bool `xorm:"is_disabled NOT NULL DEFAULT false"` Created timeutil.TimeStamp `xorm:"created"` Updated timeutil.TimeStamp `xorm:"updated"` @@ -173,6 +176,13 @@ func (r *ActionRunner) GenerateToken() (err error) { return err } +// CanMatchLabels checks whether the runner's labels can match a job's "runs-on" +// See https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idruns-on +func (r *ActionRunner) CanMatchLabels(jobRunsOn []string) bool { + runnerLabelSet := container.SetOf(r.AgentLabels...) + return runnerLabelSet.Contains(jobRunsOn...) // match all labels +} + func init() { db.RegisterModel(&ActionRunner{}) } @@ -191,6 +201,7 @@ type FindRunnerOptions struct { Sort string Filter string IsOnline optional.Option[bool] + IsDisabled optional.Option[bool] WithAvailable bool // not only runners belong to, but also runners can be used } @@ -231,6 +242,10 @@ func (opts FindRunnerOptions) ToConds() builder.Cond { cond = cond.And(builder.Lte{"last_online": time.Now().Add(-RunnerOfflineTime).Unix()}) } } + + if opts.IsDisabled.Has() { + cond = cond.And(builder.Eq{"is_disabled": opts.IsDisabled.Value()}) + } return cond } @@ -289,6 +304,20 @@ func UpdateRunner(ctx context.Context, r *ActionRunner, cols ...string) error { return err } +func SetRunnerDisabled(ctx context.Context, runner *ActionRunner, isDisabled bool) error { + if runner.IsDisabled == isDisabled { + return nil + } + + return db.WithTx(ctx, func(ctx context.Context) error { + runner.IsDisabled = isDisabled + if err := UpdateRunner(ctx, runner, "is_disabled"); err != nil { + return err + } + return IncreaseTaskVersion(ctx, runner.OwnerID, runner.RepoID) + }) +} + // DeleteRunner deletes a runner by given ID. func DeleteRunner(ctx context.Context, id int64) error { if _, err := GetRunnerByID(ctx, id); err != nil { diff --git a/models/actions/task.go b/models/actions/task.go index c1306a84182..e092d6fbbd9 100644 --- a/models/actions/task.go +++ b/models/actions/task.go @@ -8,12 +8,13 @@ import ( "crypto/subtle" "errors" "fmt" + "strings" "time" auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/unit" - "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/actions/jobparser" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" @@ -21,7 +22,6 @@ import ( runnerv1 "code.gitea.io/actions-proto-go/runner/v1" lru "github.com/hashicorp/golang-lru/v2" - "github.com/nektos/act/pkg/jobparser" "google.golang.org/protobuf/types/known/timestamppb" "xorm.io/builder" ) @@ -114,7 +114,7 @@ func (task *ActionTask) GetRepoLink() string { func (task *ActionTask) LoadJob(ctx context.Context) error { if task.Job == nil { - job, err := GetRunJobByID(ctx, task.JobID) + job, err := GetRunJobByRepoAndID(ctx, task.RepoID, task.JobID) if err != nil { return err } @@ -216,6 +216,20 @@ func GetRunningTaskByToken(ctx context.Context, token string) (*ActionTask, erro return nil, errNotExist } +func makeTaskStepDisplayName(step *jobparser.Step, limit int) (name string) { + if step.Name != "" { + name = step.Name // the step has an explicit name + } else { + // for unnamed step, its "String()" method tries to get a display name by its "name", "uses", + // "run" or "id" (last fallback), we add the "Run " prefix for unnamed steps for better display + // for multi-line "run" scripts, only use the first line to match GitHub's behavior + // https://github.com/actions/runner/blob/66800900843747f37591b077091dd2c8cf2c1796/src/Runner.Worker/Handlers/ScriptHandler.cs#L45-L58 + runStr, _, _ := strings.Cut(strings.TrimSpace(step.Run), "\n") + name = "Run " + util.IfZero(strings.TrimSpace(runStr), step.String()) + } + return util.EllipsisDisplayString(name, limit) // database column has a length limit +} + func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask, bool, error) { ctx, committer, err := db.TxContext(ctx) if err != nil { @@ -246,7 +260,7 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask var job *ActionRunJob log.Trace("runner labels: %v", runner.AgentLabels) for _, v := range jobs { - if isSubset(runner.AgentLabels, v.RunsOn) { + if runner.CanMatchLabels(v.RunsOn) { job = v break } @@ -278,13 +292,10 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask return nil, false, err } - parsedWorkflows, err := jobparser.Parse(job.WorkflowPayload) + workflowJob, err := job.ParseJob() if err != nil { - return nil, false, fmt.Errorf("parse workflow of job %d: %w", job.ID, err) - } else if len(parsedWorkflows) != 1 { - return nil, false, fmt.Errorf("workflow of job %d: not single workflow", job.ID) + return nil, false, fmt.Errorf("load job %d: %w", job.ID, err) } - _, workflowJob := parsedWorkflows[0].Job() if _, err := e.Insert(task); err != nil { return nil, false, err @@ -298,9 +309,8 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask if len(workflowJob.Steps) > 0 { steps := make([]*ActionTaskStep, len(workflowJob.Steps)) for i, v := range workflowJob.Steps { - name := util.EllipsisDisplayString(v.String(), 255) steps[i] = &ActionTaskStep{ - Name: name, + Name: makeTaskStepDisplayName(v, 255), TaskID: task.ID, Index: int64(i), RepoID: task.RepoID, @@ -378,6 +388,7 @@ func UpdateTaskByState(ctx context.Context, runnerID int64, state *runnerv1.Task } if _, err := UpdateRunJob(ctx, &ActionRunJob{ ID: task.JobID, + RepoID: task.RepoID, Status: task.Status, Stopped: task.Stopped, }, nil); err != nil { @@ -439,6 +450,7 @@ func StopTask(ctx context.Context, taskID int64, status Status) error { task.Stopped = now if _, err := UpdateRunJob(ctx, &ActionRunJob{ ID: task.JobID, + RepoID: task.RepoID, Status: task.Status, Stopped: task.Stopped, }, nil); err != nil { @@ -479,20 +491,6 @@ func FindOldTasksToExpire(ctx context.Context, olderThan timeutil.TimeStamp, lim Find(&tasks) } -func isSubset(set, subset []string) bool { - m := make(container.Set[string], len(set)) - for _, v := range set { - m.Add(v) - } - - for _, v := range subset { - if !m.Contains(v) { - return false - } - } - return true -} - func convertTimestamp(timestamp *timestamppb.Timestamp) timeutil.TimeStamp { if timestamp.GetSeconds() == 0 && timestamp.GetNanos() == 0 { return timeutil.TimeStamp(0) diff --git a/models/actions/task_step.go b/models/actions/task_step.go index 3af1fe3f5a8..03ffbf19316 100644 --- a/models/actions/task_step.go +++ b/models/actions/task_step.go @@ -14,7 +14,7 @@ import ( // ActionTaskStep represents a step of ActionTask type ActionTaskStep struct { ID int64 - Name string `xorm:"VARCHAR(255)"` + Name string `xorm:"VARCHAR(255)"` // the step name, for display purpose only, it will be truncated if it is too long TaskID int64 `xorm:"index unique(task_index)"` Index int64 `xorm:"index unique(task_index)"` RepoID int64 `xorm:"index"` diff --git a/models/actions/task_test.go b/models/actions/task_test.go new file mode 100644 index 00000000000..cbfc6f7636b --- /dev/null +++ b/models/actions/task_test.go @@ -0,0 +1,77 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "strings" + "testing" + + "code.gitea.io/gitea/modules/actions/jobparser" + + "github.com/stretchr/testify/assert" +) + +func TestMakeTaskStepDisplayName(t *testing.T) { + tests := []struct { + name string + jobStep *jobparser.Step + expected string + }{ + { + name: "explicit name", + jobStep: &jobparser.Step{ + Name: "Test Step", + }, + expected: "Test Step", + }, + { + name: "uses step", + jobStep: &jobparser.Step{ + Uses: "actions/checkout@v4", + }, + expected: "Run actions/checkout@v4", + }, + { + name: "single-line run", + jobStep: &jobparser.Step{ + Run: "echo hello", + }, + expected: "Run echo hello", + }, + { + name: "multi-line run block scalar", + jobStep: &jobparser.Step{ + Run: "\n echo hello \r\n echo world \n ", + }, + expected: "Run echo hello", + }, + { + name: "fallback to id", + jobStep: &jobparser.Step{ + ID: "step-id", + }, + expected: "Run step-id", + }, + { + name: "very long name truncated", + jobStep: &jobparser.Step{ + Name: strings.Repeat("a", 300), + }, + expected: strings.Repeat("a", 252) + "…", + }, + { + name: "very long run truncated", + jobStep: &jobparser.Step{ + Run: strings.Repeat("a", 300), + }, + expected: "Run " + strings.Repeat("a", 248) + "…", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := makeTaskStepDisplayName(tt.jobStep, 255) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/models/activities/action_list.go b/models/activities/action_list.go index b52cf7ee497..29ff2fdf7a2 100644 --- a/models/activities/action_list.go +++ b/models/activities/action_list.go @@ -30,7 +30,7 @@ func (actions ActionList) getUserIDs() []int64 { func (actions ActionList) LoadActUsers(ctx context.Context) (map[int64]*user_model.User, error) { if len(actions) == 0 { - return nil, nil + return nil, nil //nolint:nilnil // returns nil when there are no actions } userIDs := actions.getUserIDs() diff --git a/models/activities/notification.go b/models/activities/notification.go index b482e6020af..8a830c5aa26 100644 --- a/models/activities/notification.go +++ b/models/activities/notification.go @@ -386,7 +386,7 @@ func SetNotificationStatus(ctx context.Context, notificationID int64, user *user notification.Status = status - _, err = db.GetEngine(ctx).ID(notificationID).Update(notification) + _, err = db.GetEngine(ctx).ID(notificationID).Cols("status").Update(notification) return notification, err } diff --git a/models/activities/notification_list.go b/models/activities/notification_list.go index 6539e14ea27..d38758bc41a 100644 --- a/models/activities/notification_list.go +++ b/models/activities/notification_list.go @@ -113,7 +113,7 @@ func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, n } toNotify.AddMultiple(issueParticipants...) - // dont notify user who cause notification + // don't notify user who cause notification delete(toNotify, notificationAuthorID) // explicit unwatch on issue issueUnWatches, err := issues_model.GetIssueWatchersIDs(ctx, issueID, false) diff --git a/models/activities/user_heatmap.go b/models/activities/user_heatmap.go index ef67838be73..e24d44c5195 100644 --- a/models/activities/user_heatmap.go +++ b/models/activities/user_heatmap.go @@ -19,14 +19,14 @@ type UserHeatmapData struct { Contributions int64 `json:"contributions"` } -// GetUserHeatmapDataByUser returns an array of UserHeatmapData +// GetUserHeatmapDataByUser returns an array of UserHeatmapData, it checks whether doer can access user's activity func GetUserHeatmapDataByUser(ctx context.Context, user, doer *user_model.User) ([]*UserHeatmapData, error) { return getUserHeatmapData(ctx, user, nil, doer) } -// GetUserHeatmapDataByUserTeam returns an array of UserHeatmapData -func GetUserHeatmapDataByUserTeam(ctx context.Context, user *user_model.User, team *organization.Team, doer *user_model.User) ([]*UserHeatmapData, error) { - return getUserHeatmapData(ctx, user, team, doer) +// GetUserHeatmapDataByOrgTeam returns an array of UserHeatmapData, it checks whether doer can access org's activity +func GetUserHeatmapDataByOrgTeam(ctx context.Context, org *organization.Organization, team *organization.Team, doer *user_model.User) ([]*UserHeatmapData, error) { + return getUserHeatmapData(ctx, org.AsUser(), team, doer) } func getUserHeatmapData(ctx context.Context, user *user_model.User, team *organization.Team, doer *user_model.User) ([]*UserHeatmapData, error) { @@ -71,12 +71,3 @@ func getUserHeatmapData(ctx context.Context, user *user_model.User, team *organi OrderBy("timestamp"). Find(&hdata) } - -// GetTotalContributionsInHeatmap returns the total number of contributions in a heatmap -func GetTotalContributionsInHeatmap(hdata []*UserHeatmapData) int64 { - var total int64 - for _, v := range hdata { - total += v.Contributions - } - return total -} diff --git a/models/admin/task.go b/models/admin/task.go index 0541a8ec78e..5d2b9bbff6f 100644 --- a/models/admin/task.go +++ b/models/admin/task.go @@ -11,6 +11,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/migration" "code.gitea.io/gitea/modules/secret" "code.gitea.io/gitea/modules/setting" @@ -123,17 +124,17 @@ func (task *Task) MigrateConfig() (*migration.MigrateOptions, error) { // decrypt credentials if opts.CloneAddrEncrypted != "" { if opts.CloneAddr, err = secret.DecryptSecret(setting.SecretKey, opts.CloneAddrEncrypted); err != nil { - return nil, err + log.Error("Unable to decrypt CloneAddr, maybe SECRET_KEY is wrong: %v", err) } } if opts.AuthPasswordEncrypted != "" { if opts.AuthPassword, err = secret.DecryptSecret(setting.SecretKey, opts.AuthPasswordEncrypted); err != nil { - return nil, err + log.Error("Unable to decrypt AuthPassword, maybe SECRET_KEY is wrong: %v", err) } } if opts.AuthTokenEncrypted != "" { if opts.AuthToken, err = secret.DecryptSecret(setting.SecretKey, opts.AuthTokenEncrypted); err != nil { - return nil, err + log.Error("Unable to decrypt AuthToken, maybe SECRET_KEY is wrong: %v", err) } } diff --git a/models/asymkey/gpg_key_commit_verification.go b/models/asymkey/gpg_key_commit_verification.go index 375b703f7b3..ae5192de9fd 100644 --- a/models/asymkey/gpg_key_commit_verification.go +++ b/models/asymkey/gpg_key_commit_verification.go @@ -70,7 +70,7 @@ func hashAndVerify(sig *packet.Signature, payload string, k *GPGKey) (*GPGKey, e // We will ignore errors in verification as they don't need to be propagated up err = verifySign(sig, hash, k) if err != nil { - return nil, nil + return nil, nil //nolint:nilnil // verification failed, not an error } return k, nil } @@ -86,7 +86,7 @@ func hashAndVerifyWithSubKeys(sig *packet.Signature, payload string, k *GPGKey) return verified, err } } - return nil, nil + return nil, nil //nolint:nilnil // verification failed, not an error } func HashAndVerifyWithSubKeysCommitVerification(sig *packet.Signature, payload string, k *GPGKey, committer, signer *user_model.User, email string) *CommitVerification { diff --git a/models/asymkey/gpg_key_test.go b/models/asymkey/gpg_key_test.go index 4621337f11b..e6656cb70d6 100644 --- a/models/asymkey/gpg_key_test.go +++ b/models/asymkey/gpg_key_test.go @@ -11,7 +11,6 @@ import ( "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/timeutil" - "code.gitea.io/gitea/modules/util" "github.com/ProtonMail/go-crypto/openpgp" "github.com/ProtonMail/go-crypto/openpgp/packet" @@ -398,7 +397,7 @@ epiDVQ== func TestTryGetKeyIDFromSignature(t *testing.T) { assert.Empty(t, TryGetKeyIDFromSignature(&packet.Signature{})) assert.Equal(t, "038D1A3EADDBEA9C", TryGetKeyIDFromSignature(&packet.Signature{ - IssuerKeyId: util.ToPointer(uint64(0x38D1A3EADDBEA9C)), + IssuerKeyId: new(uint64(0x38D1A3EADDBEA9C)), })) assert.Equal(t, "038D1A3EADDBEA9C", TryGetKeyIDFromSignature(&packet.Signature{ IssuerFingerprint: []uint8{0xb, 0x23, 0x24, 0xc7, 0xe6, 0xfe, 0x4f, 0x3a, 0x6, 0x26, 0xc1, 0x21, 0x3, 0x8d, 0x1a, 0x3e, 0xad, 0xdb, 0xea, 0x9c}, @@ -419,7 +418,7 @@ func TestParseGPGKey(t *testing.T) { // then revoke the key for _, id := range e.Identities { - id.Revocations = append(id.Revocations, &packet.Signature{RevocationReason: util.ToPointer(packet.KeyCompromised)}) + id.Revocations = append(id.Revocations, &packet.Signature{RevocationReason: new(packet.KeyCompromised)}) } k, err = parseGPGKey(t.Context(), 1, e, true) require.NoError(t, err) diff --git a/models/asymkey/gpg_key_verify.go b/models/asymkey/gpg_key_verify.go index 55c64973b41..5df0265c880 100644 --- a/models/asymkey/gpg_key_verify.go +++ b/models/asymkey/gpg_key_verify.go @@ -78,7 +78,7 @@ func VerifyGPGKey(ctx context.Context, ownerID int64, keyID, token, signature st } key.Verified = true - if _, err := db.GetEngine(ctx).ID(key.ID).SetExpr("verified", true).Update(new(GPGKey)); err != nil { + if _, err := db.GetEngine(ctx).ID(key.ID).Cols("verified").Update(key); err != nil { return "", err } diff --git a/models/asymkey/ssh_key.go b/models/asymkey/ssh_key.go index 87205f0651e..98784b36bd3 100644 --- a/models/asymkey/ssh_key.go +++ b/models/asymkey/ssh_key.go @@ -67,13 +67,6 @@ func (key *PublicKey) OmitEmail() string { return strings.Join(strings.Split(key.Content, " ")[:2], " ") } -// AuthorizedString returns formatted public key string for authorized_keys file. -// -// TODO: Consider dropping this function -func (key *PublicKey) AuthorizedString() string { - return AuthorizedStringForKey(key) -} - func addKey(ctx context.Context, key *PublicKey) (err error) { if len(key.Fingerprint) == 0 { key.Fingerprint, err = CalcFingerprint(key.Content) @@ -91,7 +84,7 @@ func addKey(ctx context.Context, key *PublicKey) (err error) { } // AddPublicKey adds new public key to database and authorized_keys file. -func AddPublicKey(ctx context.Context, ownerID int64, name, content string, authSourceID int64) (*PublicKey, error) { +func AddPublicKey(ctx context.Context, ownerID int64, name, content string, authSourceID int64, verified bool) (*PublicKey, error) { log.Trace(content) fingerprint, err := CalcFingerprint(content) @@ -122,6 +115,7 @@ func AddPublicKey(ctx context.Context, ownerID int64, name, content string, auth Mode: perm.AccessModeWrite, Type: KeyTypeUser, LoginSourceID: authSourceID, + Verified: verified, } if err = addKey(ctx, key); err != nil { return nil, fmt.Errorf("addKey: %w", err) @@ -305,7 +299,7 @@ func deleteKeysMarkedForDeletion(ctx context.Context, keys []string) (bool, erro } // AddPublicKeysBySource add a users public keys. Returns true if there are changes. -func AddPublicKeysBySource(ctx context.Context, usr *user_model.User, s *auth.Source, sshPublicKeys []string) bool { +func AddPublicKeysBySource(ctx context.Context, usr *user_model.User, s *auth.Source, sshPublicKeys []string, verified bool) bool { var sshKeysNeedUpdate bool for _, sshKey := range sshPublicKeys { var err error @@ -324,7 +318,7 @@ func AddPublicKeysBySource(ctx context.Context, usr *user_model.User, s *auth.So marshalled = marshalled[:len(marshalled)-1] sshKeyName := fmt.Sprintf("%s-%s", s.Name, ssh.FingerprintSHA256(out)) - if _, err := AddPublicKey(ctx, usr.ID, sshKeyName, marshalled, s.ID); err != nil { + if _, err := AddPublicKey(ctx, usr.ID, sshKeyName, marshalled, s.ID, verified); err != nil { if IsErrKeyAlreadyExist(err) { log.Trace("AddPublicKeysBySource[%s]: Public SSH Key %s already exists for user", sshKeyName, usr.Name) } else { @@ -343,7 +337,7 @@ func AddPublicKeysBySource(ctx context.Context, usr *user_model.User, s *auth.So } // SynchronizePublicKeys updates a user's public keys. Returns true if there are changes. -func SynchronizePublicKeys(ctx context.Context, usr *user_model.User, s *auth.Source, sshPublicKeys []string) bool { +func SynchronizePublicKeys(ctx context.Context, usr *user_model.User, s *auth.Source, sshPublicKeys []string, verified bool) bool { var sshKeysNeedUpdate bool log.Trace("synchronizePublicKeys[%s]: Handling Public SSH Key synchronization for user %s", s.Name, usr.Name) @@ -388,7 +382,7 @@ func SynchronizePublicKeys(ctx context.Context, usr *user_model.User, s *auth.So newKeys = append(newKeys, key) } } - if AddPublicKeysBySource(ctx, usr, s, newKeys) { + if AddPublicKeysBySource(ctx, usr, s, newKeys, verified) { sshKeysNeedUpdate = true } diff --git a/models/asymkey/ssh_key_authorized_keys.go b/models/asymkey/ssh_key_authorized_keys.go index 2e4cd62e5cf..cb2943e31df 100644 --- a/models/asymkey/ssh_key_authorized_keys.go +++ b/models/asymkey/ssh_key_authorized_keys.go @@ -10,6 +10,7 @@ import ( "io" "os" "path/filepath" + "regexp" "strings" "sync" @@ -17,29 +18,13 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" + + "golang.org/x/crypto/ssh" ) -// _____ __ .__ .__ .___ -// / _ \ __ ___/ |_| |__ ___________|__|_______ ____ __| _/ -// / /_\ \| | \ __\ | \ / _ \_ __ \ \___ // __ \ / __ | -// / | \ | /| | | Y ( <_> ) | \/ |/ /\ ___// /_/ | -// \____|__ /____/ |__| |___| /\____/|__| |__/_____ \\___ >____ | -// \/ \/ \/ \/ \/ -// ____ __. -// | |/ _|____ ___.__. ______ -// | <_/ __ < | |/ ___/ -// | | \ ___/\___ |\___ \ -// |____|__ \___ > ____/____ > -// \/ \/\/ \/ -// -// This file contains functions for creating authorized_keys files -// -// There is a dependence on the database within RegeneratePublicKeys however most of these functions probably belong in a module - -const ( - tplCommentPrefix = `# gitea public key` - tplPublicKey = tplCommentPrefix + "\n" + `command=%s,no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,no-user-rc,restrict %s` + "\n" -) +// AuthorizedStringCommentPrefix is a magic tag +// some functions like RegeneratePublicKeys needs this tag to skip the keys generated by Gitea, while keep other keys +const AuthorizedStringCommentPrefix = `# gitea public key` var sshOpLocker sync.Mutex @@ -50,17 +35,73 @@ func WithSSHOpLocker(f func() error) error { } // AuthorizedStringForKey creates the authorized keys string appropriate for the provided key -func AuthorizedStringForKey(key *PublicKey) string { +func AuthorizedStringForKey(key *PublicKey) (string, error) { sb := &strings.Builder{} - _ = setting.SSH.AuthorizedKeysCommandTemplateTemplate.Execute(sb, map[string]any{ + _, err := writeAuthorizedStringForKey(key, sb) + return sb.String(), err +} + +// WriteAuthorizedStringForValidKey writes the authorized key for the provided key. If the key is invalid, it does nothing. +func WriteAuthorizedStringForValidKey(key *PublicKey, w io.Writer) error { + validKey, err := writeAuthorizedStringForKey(key, w) + if !validKey { + log.Debug("WriteAuthorizedStringForValidKey: key %s is not valid: %v", key, err) + return nil + } + return err +} + +var globalVars = sync.OnceValue(func() (ret struct { + principalRegexp *regexp.Regexp +}, +) { + // principalRegexp expresses whether a principal is considered valid. + // This reverse engineers how sshd parses the authorized keys file, + // see e.g. https://github.com/openssh/openssh-portable/blob/32deb00b38b4ee2b3302f261ea1e68c04e020a08/auth2-pubkeyfile.c#L221-L256 + // Any newline or # comment will be stripped when parsing, so don't allow + // those. Also, if any space or tab is present in the principal, the part + // proceeding this would be parsed as an option, so just avoid any whitespace + // altogether. + ret.principalRegexp = regexp.MustCompile(`^[^\s#]+$`) + return ret +}) + +func writeAuthorizedStringForKey(key *PublicKey, w io.Writer) (keyValid bool, err error) { + const tpl = AuthorizedStringCommentPrefix + "\n" + `command=%s,no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,no-user-rc,restrict %s` + "\n" + + var sshKey string + + if key.Type == KeyTypePrincipal { + // TODO: actually using PublicKey to store "principal" is an abuse + if !globalVars().principalRegexp.MatchString(key.Content) { + return false, fmt.Errorf("invalid principal key: %s", key.Content) + } + sshKey = fmt.Sprintf("%s # user-%d", key.Content, key.OwnerID) + } else { + pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key.Content)) + if err != nil { + return false, err + } + + sshKeyMarshalled := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pubKey))) + sshKey = fmt.Sprintf("%s user-%d", sshKeyMarshalled, key.OwnerID) + } + + // now the key is valid, the code below could only return template/IO related errors + sbCmd := &strings.Builder{} + err = setting.SSH.AuthorizedKeysCommandTemplateTemplate.Execute(sbCmd, map[string]any{ "AppPath": util.ShellEscape(setting.AppPath), "AppWorkPath": util.ShellEscape(setting.AppWorkPath), "CustomConf": util.ShellEscape(setting.CustomConf), "CustomPath": util.ShellEscape(setting.CustomPath), "Key": key, }) - - return fmt.Sprintf(tplPublicKey, util.ShellEscape(sb.String()), key.Content) + if err != nil { + return true, err + } + sshCommandEscaped := util.ShellEscape(sbCmd.String()) + _, err = fmt.Fprintf(w, tpl, sshCommandEscaped, sshKey) + return true, err } // appendAuthorizedKeysToFile appends new SSH keys' content to authorized_keys file. @@ -112,7 +153,7 @@ func appendAuthorizedKeysToFile(keys ...*PublicKey) error { if key.Type == KeyTypePrincipal { continue } - if _, err = f.WriteString(key.AuthorizedString()); err != nil { + if err = WriteAuthorizedStringForValidKey(key, f); err != nil { return err } } @@ -120,10 +161,9 @@ func appendAuthorizedKeysToFile(keys ...*PublicKey) error { } // RegeneratePublicKeys regenerates the authorized_keys file -func RegeneratePublicKeys(ctx context.Context, t io.StringWriter) error { +func RegeneratePublicKeys(ctx context.Context, t io.Writer) error { if err := db.GetEngine(ctx).Where("type != ?", KeyTypePrincipal).Iterate(new(PublicKey), func(idx int, bean any) (err error) { - _, err = t.WriteString((bean.(*PublicKey)).AuthorizedString()) - return err + return WriteAuthorizedStringForValidKey(bean.(*PublicKey), t) }); err != nil { return err } @@ -144,11 +184,11 @@ func RegeneratePublicKeys(ctx context.Context, t io.StringWriter) error { scanner := bufio.NewScanner(f) for scanner.Scan() { line := scanner.Text() - if strings.HasPrefix(line, tplCommentPrefix) { + if strings.HasPrefix(line, AuthorizedStringCommentPrefix) { scanner.Scan() continue } - _, err = t.WriteString(line + "\n") + _, err = io.WriteString(t, line+"\n") if err != nil { return err } diff --git a/models/asymkey/ssh_key_authorized_keys_test.go b/models/asymkey/ssh_key_authorized_keys_test.go new file mode 100644 index 00000000000..36ed57a6537 --- /dev/null +++ b/models/asymkey/ssh_key_authorized_keys_test.go @@ -0,0 +1,90 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package asymkey + +import ( + "strings" + "testing" + + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + + "github.com/stretchr/testify/assert" +) + +func TestWriteAuthorizedStringForKey(t *testing.T) { + defer test.MockVariableValue(&setting.AppPath, "/tmp/gitea")() + defer test.MockVariableValue(&setting.CustomConf, "/tmp/app.ini")() + writeKey := func(t *testing.T, key *PublicKey) (bool, string, error) { + sb := &strings.Builder{} + valid, err := writeAuthorizedStringForKey(key, sb) + return valid, sb.String(), err + } + const validKeyContent = `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICV0MGX/W9IvLA4FXpIuUcdDcbj5KX4syHgsTy7soVgf` + + testValid := func(t *testing.T, key *PublicKey, expected string) { + valid, content, err := writeKey(t, key) + assert.True(t, valid) + assert.Equal(t, expected, content) + assert.NoError(t, err) + } + + testInvalid := func(t *testing.T, key *PublicKey) { + valid, content, err := writeKey(t, key) + assert.False(t, valid) + assert.Empty(t, content) + assert.Error(t, err) + } + + t.Run("PublicKey", func(t *testing.T) { + testValid(t, &PublicKey{ + OwnerID: 123, + Content: validKeyContent + " any-comment", + Type: KeyTypeUser, + }, `# gitea public key +command="/tmp/gitea --config=/tmp/app.ini serv key-0",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,no-user-rc,restrict ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICV0MGX/W9IvLA4FXpIuUcdDcbj5KX4syHgsTy7soVgf user-123 +`) + }) + + t.Run("PublicKeyWithNewLine", func(t *testing.T) { + testValid(t, &PublicKey{ + OwnerID: 123, + Content: validKeyContent + "\nany-more", // the new line should be ignored + Type: KeyTypeUser, + }, `# gitea public key +command="/tmp/gitea --config=/tmp/app.ini serv key-0",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,no-user-rc,restrict ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICV0MGX/W9IvLA4FXpIuUcdDcbj5KX4syHgsTy7soVgf user-123 +`) + }) + + t.Run("PublicKeyInvalid", func(t *testing.T) { + testInvalid(t, &PublicKey{ + OwnerID: 123, + Content: validKeyContent + "any-more", + Type: KeyTypeUser, + }) + }) + + t.Run("Principal", func(t *testing.T) { + testValid(t, &PublicKey{ + OwnerID: 123, + Content: "any-content", + Type: KeyTypePrincipal, + }, `# gitea public key +command="/tmp/gitea --config=/tmp/app.ini serv key-0",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,no-user-rc,restrict any-content # user-123 +`) + }) + + t.Run("PrincipalInvalid", func(t *testing.T) { + testInvalid(t, &PublicKey{ + OwnerID: 123, + Content: "a b", + Type: KeyTypePrincipal, + }) + testInvalid(t, &PublicKey{ + OwnerID: 123, + Content: "a\nb", + Type: KeyTypePrincipal, + }) + }) +} diff --git a/models/auth/oauth2.go b/models/auth/oauth2.go index d6648413069..e2bb72b722a 100644 --- a/models/auth/oauth2.go +++ b/models/auth/oauth2.go @@ -14,6 +14,7 @@ import ( "net/url" "slices" "strings" + "time" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/container" @@ -27,6 +28,11 @@ import ( "xorm.io/xorm" ) +// Authorization codes should expire within 10 minutes per https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2 +const oauth2AuthorizationCodeValidity = 10 * time.Minute + +var ErrOAuth2AuthorizationCodeInvalidated = errors.New("oauth2 authorization code already invalidated") + // OAuth2Application represents an OAuth2 client (RFC 6749) type OAuth2Application struct { ID int64 `xorm:"pk autoincr"` @@ -209,7 +215,7 @@ func (app *OAuth2Application) GetGrantByUserID(ctx context.Context, userID int64 if has, err := db.GetEngine(ctx).Where("user_id = ? AND application_id = ?", userID, app.ID).Get(grant); err != nil { return nil, err } else if !has { - return nil, nil + return nil, nil //nolint:nilnil // return nil to indicate that the object does not exist } return grant, nil } @@ -386,6 +392,14 @@ func (code *OAuth2AuthorizationCode) TableName() string { return "oauth2_authorization_code" } +// IsExpired reports whether the authorization code is expired. +func (code *OAuth2AuthorizationCode) IsExpired() bool { + if code.ValidUntil.IsZero() { + return true + } + return code.ValidUntil <= timeutil.TimeStampNow() +} + // GenerateRedirectURI generates a redirect URI for a successful authorization request. State will be used if not empty. func (code *OAuth2AuthorizationCode) GenerateRedirectURI(state string) (*url.URL, error) { redirect, err := url.Parse(code.RedirectURI) @@ -403,8 +417,14 @@ func (code *OAuth2AuthorizationCode) GenerateRedirectURI(state string) (*url.URL // Invalidate deletes the auth code from the database to invalidate this code func (code *OAuth2AuthorizationCode) Invalidate(ctx context.Context) error { - _, err := db.GetEngine(ctx).ID(code.ID).NoAutoCondition().Delete(code) - return err + affected, err := db.GetEngine(ctx).ID(code.ID).NoAutoCondition().Delete(code) + if err != nil { + return err + } + if affected == 0 { + return ErrOAuth2AuthorizationCodeInvalidated + } + return nil } // ValidateCodeChallenge validates the given verifier against the saved code challenge. This is part of the PKCE implementation. @@ -431,13 +451,13 @@ func GetOAuth2AuthorizationByCode(ctx context.Context, code string) (auth *OAuth if has, err := db.GetEngine(ctx).Where("code = ?", code).Get(auth); err != nil { return nil, err } else if !has { - return nil, nil + return nil, nil //nolint:nilnil // return nil to indicate that the object does not exist } auth.Grant = new(OAuth2Grant) if has, err := db.GetEngine(ctx).ID(auth.GrantID).Get(auth.Grant); err != nil { return nil, err } else if !has { - return nil, nil + return nil, nil //nolint:nilnil // return nil to indicate that the object does not exist } return auth, nil } @@ -472,6 +492,7 @@ func (grant *OAuth2Grant) GenerateNewAuthorizationCode(ctx context.Context, redi // for code scanners to grab sensitive tokens. codeSecret := "gta_" + base32Lower.EncodeToString(rBytes) + validUntil := time.Now().Add(oauth2AuthorizationCodeValidity) code = &OAuth2AuthorizationCode{ Grant: grant, GrantID: grant.ID, @@ -479,6 +500,7 @@ func (grant *OAuth2Grant) GenerateNewAuthorizationCode(ctx context.Context, redi Code: codeSecret, CodeChallenge: codeChallenge, CodeChallengeMethod: codeChallengeMethod, + ValidUntil: timeutil.TimeStamp(validUntil.Unix()), } if err := db.Insert(ctx, code); err != nil { return nil, err @@ -521,7 +543,7 @@ func GetOAuth2GrantByID(ctx context.Context, id int64) (grant *OAuth2Grant, err if has, err := db.GetEngine(ctx).ID(id).Get(grant); err != nil { return nil, err } else if !has { - return nil, nil + return nil, nil //nolint:nilnil // return nil to indicate that the object does not exist } return grant, err } diff --git a/models/auth/oauth2_test.go b/models/auth/oauth2_test.go index 97f750755a2..88ae065652c 100644 --- a/models/auth/oauth2_test.go +++ b/models/auth/oauth2_test.go @@ -5,13 +5,45 @@ package auth_test import ( "testing" + "time" auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/timeutil" "github.com/stretchr/testify/assert" ) +func TestOAuth2AuthorizationCodeValidity(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + t.Run("GenerateSetsValidUntil", func(t *testing.T) { + grant := unittest.AssertExistsAndLoadBean(t, &auth_model.OAuth2Grant{ID: 1}) + expectedValidUntil := timeutil.TimeStamp(time.Now().Unix() + 600) + code, err := grant.GenerateNewAuthorizationCode(t.Context(), "http://127.0.0.1/", "", "") + assert.NoError(t, err) + assert.Equal(t, expectedValidUntil, code.ValidUntil) + assert.False(t, code.IsExpired()) + assert.NoError(t, code.Invalidate(t.Context())) + }) + + t.Run("Expired", func(t *testing.T) { + defer timeutil.MockSet(time.Unix(2, 0).UTC())() + + code := &auth_model.OAuth2AuthorizationCode{ValidUntil: timeutil.TimeStamp(1)} + assert.True(t, code.IsExpired()) + }) + + t.Run("InvalidateTwice", func(t *testing.T) { + code, err := auth_model.GetOAuth2AuthorizationByCode(t.Context(), "authcode") + assert.NoError(t, err) + if assert.NotNil(t, code) { + assert.NoError(t, code.Invalidate(t.Context())) + assert.ErrorIs(t, code.Invalidate(t.Context()), auth_model.ErrOAuth2AuthorizationCodeInvalidated) + } + }) +} + func TestOAuth2Application_GenerateClientSecret(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) app := unittest.AssertExistsAndLoadBean(t, &auth_model.OAuth2Application{ID: 1}) diff --git a/models/auth/source.go b/models/auth/source.go index 08cfc9615b0..c0b262f870a 100644 --- a/models/auth/source.go +++ b/models/auth/source.go @@ -117,7 +117,7 @@ func RegisterTypeConfig(typ Type, exemplar Config) { type Source struct { ID int64 `xorm:"pk autoincr"` Type Type - Name string `xorm:"UNIQUE"` + Name string `xorm:"UNIQUE"` // it can be the OIDC's provider name, see services/auth/source/oauth2/source_register.go: RegisterSource IsActive bool `xorm:"INDEX NOT NULL DEFAULT false"` IsSyncEnabled bool `xorm:"INDEX NOT NULL DEFAULT false"` TwoFactorPolicy string `xorm:"two_factor_policy NOT NULL DEFAULT ''"` diff --git a/models/auth/twofactor.go b/models/auth/twofactor.go index 200ce7c7c0e..4263495650f 100644 --- a/models/auth/twofactor.go +++ b/models/auth/twofactor.go @@ -111,11 +111,11 @@ func (t *TwoFactor) SetSecret(secretString string) error { func (t *TwoFactor) ValidateTOTP(passcode string) (bool, error) { decodedStoredSecret, err := base64.StdEncoding.DecodeString(t.Secret) if err != nil { - return false, err + return false, fmt.Errorf("ValidateTOTP invalid base64: %w", err) } secretBytes, err := secret.AesDecrypt(t.getEncryptionKey(), decodedStoredSecret) if err != nil { - return false, err + return false, fmt.Errorf("ValidateTOTP unable to decrypt (maybe SECRET_KEY is wrong): %w", err) } secretStr := string(secretBytes) return totp.Validate(passcode, secretStr), nil diff --git a/models/db/collation.go b/models/db/collation.go index 79ade873803..203f7cbfe47 100644 --- a/models/db/collation.go +++ b/models/db/collation.go @@ -98,7 +98,7 @@ func CheckCollations(x *xorm.Engine) (*CheckCollationsResult, error) { return nil, err } } else { - return nil, nil + return nil, nil //nolint:nilnil // return nil for unsupported database types } if res.DatabaseCollation == "" { diff --git a/models/db/common.go b/models/db/common.go index ea628bf2a0a..b3c43f8b626 100644 --- a/models/db/common.go +++ b/models/db/common.go @@ -12,30 +12,30 @@ import ( "xorm.io/builder" ) -// BuildCaseInsensitiveLike returns a condition to check if the given value is like the given key case-insensitively. -// Handles especially SQLite correctly as UPPER there only transforms ASCII letters. +// BuildCaseInsensitiveLike returns a case-insensitive LIKE condition for the given key and value. +// Cast the search value and the database column value to the same case for case-insensitive matching. +// * SQLite: only cast ASCII chars because it doesn't handle complete Unicode case folding +// * Other databases: use database's string function, assuming that they are able to handle complete Unicode case folding correctly func BuildCaseInsensitiveLike(key, value string) builder.Cond { + // ToLowerASCII is about 7% faster than ToUpperASCII (according to Golang's benchmark) if setting.Database.Type.IsSQLite3() { - return builder.Like{"UPPER(" + key + ")", util.ToUpperASCII(value)} + return builder.Like{"LOWER(" + key + ")", util.ToLowerASCII(value)} } - return builder.Like{"UPPER(" + key + ")", strings.ToUpper(value)} + return builder.Like{"LOWER(" + key + ")", strings.ToLower(value)} } // BuildCaseInsensitiveIn returns a condition to check if the given value is in the given values case-insensitively. -// Handles especially SQLite correctly as UPPER there only transforms ASCII letters. +// See BuildCaseInsensitiveLike for more details func BuildCaseInsensitiveIn(key string, values []string) builder.Cond { - uppers := make([]string, 0, len(values)) + incaseValues := make([]string, len(values)) + caseCast := strings.ToLower if setting.Database.Type.IsSQLite3() { - for _, value := range values { - uppers = append(uppers, util.ToUpperASCII(value)) - } - } else { - for _, value := range values { - uppers = append(uppers, strings.ToUpper(value)) - } + caseCast = util.ToLowerASCII } - - return builder.In("UPPER("+key+")", uppers) + for i, value := range values { + incaseValues[i] = caseCast(value) + } + return builder.In("LOWER("+key+")", incaseValues) } // BuilderDialect returns the xorm.Builder dialect of the engine diff --git a/models/db/engine_init.go b/models/db/engine_init.go index f26189b8054..ef5db3ff5ea 100644 --- a/models/db/engine_init.go +++ b/models/db/engine_init.go @@ -57,7 +57,7 @@ func InitEngine(ctx context.Context) error { xe, err := newXORMEngine() if err != nil { if strings.Contains(err.Error(), "SQLite3 support") { - return fmt.Errorf(`sqlite3 requires: -tags sqlite,sqlite_unlock_notify%s%w`, "\n", err) + return fmt.Errorf("sqlite3 requires: -tags sqlite,sqlite_unlock_notify\n%w", err) } return fmt.Errorf("failed to connect to database: %w", err) } diff --git a/models/dbfs/dbfile.go b/models/dbfs/dbfile.go index eaf506fbe6b..a6981cb7d6a 100644 --- a/models/dbfs/dbfile.go +++ b/models/dbfs/dbfile.go @@ -75,7 +75,7 @@ func (f *file) readAt(fileMeta *dbfsMeta, offset int64, p []byte) (n int, err er } func (f *file) Read(p []byte) (n int, err error) { - if f.metaID == 0 || !f.allowRead { + if !f.allowRead { return 0, os.ErrInvalid } @@ -89,7 +89,7 @@ func (f *file) Read(p []byte) (n int, err error) { } func (f *file) Write(p []byte) (n int, err error) { - if f.metaID == 0 || !f.allowWrite { + if !f.allowWrite { return 0, os.ErrInvalid } @@ -184,10 +184,6 @@ func (f *file) Close() error { } func (f *file) Stat() (os.FileInfo, error) { - if f.metaID == 0 { - return nil, os.ErrInvalid - } - fileMeta, err := findFileMetaByID(f.ctx, f.metaID) if err != nil { return nil, err @@ -232,15 +228,17 @@ func (f *file) open(flag int) (err error) { if f.metaID != 0 { return os.ErrExist } - } else { - // create a new file if none exists. - if f.metaID == 0 { - if err = f.createEmpty(); err != nil { - return err - } + } + // create a new file if not exists. + if f.metaID == 0 { + if err = f.createEmpty(); err != nil { + return err } } } + if f.metaID == 0 { + return os.ErrNotExist + } if flag&os.O_TRUNC != 0 { if err = f.truncate(); err != nil { return err @@ -252,7 +250,7 @@ func (f *file) open(flag int) (err error) { } } return nil - } + } // end if: allowWrite // read only mode if f.metaID == 0 { @@ -322,9 +320,6 @@ func (f *file) delete() error { } func (f *file) size() (int64, error) { - if f.metaID == 0 { - return 0, os.ErrNotExist - } fileMeta, err := findFileMetaByID(f.ctx, f.metaID) if err != nil { return 0, err @@ -339,7 +334,7 @@ func findFileMetaByID(ctx context.Context, metaID int64) (*dbfsMeta, error) { } else if ok { return &fileMeta, nil } - return nil, nil + return nil, os.ErrNotExist } func buildPath(path string) string { diff --git a/models/dbfs/dbfs.go b/models/dbfs/dbfs.go index f68b4a2b70b..3f768b5339d 100644 --- a/models/dbfs/dbfs.go +++ b/models/dbfs/dbfs.go @@ -40,6 +40,9 @@ The DBFS solution: * In the future, when Gitea action needs to limit the log size (other CI/CD services also do so), it's easier to calculate the log file size. * Even sometimes the UI needs to render the tailing lines, the tailing lines can be found be counting the "\n" from the end of the file by seek. The seeking and finding is not the fastest way, but it's still acceptable and won't affect the performance too much. + +Limitations of the DBFS solution: +* Not fully POSIX-compliant, some behaviors may be different from the real filesystem, especially for concurrent read/write */ type dbfsMeta struct { diff --git a/models/dbfs/dbfs_test.go b/models/dbfs/dbfs_test.go index e1ecd871e4d..ca57ebe171a 100644 --- a/models/dbfs/dbfs_test.go +++ b/models/dbfs/dbfs_test.go @@ -9,19 +9,14 @@ import ( "os" "testing" + "code.gitea.io/gitea/modules/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func changeDefaultFileBlockSize(n int64) (restore func()) { - old := defaultFileBlockSize - defaultFileBlockSize = n - return func() { - defaultFileBlockSize = old - } -} - func TestDbfsBasic(t *testing.T) { - defer changeDefaultFileBlockSize(4)() + defer test.MockVariableValue(&defaultFileBlockSize, 4)() // test basic write/read f, err := OpenFile(t.Context(), "test.txt", os.O_RDWR|os.O_CREATE) @@ -122,10 +117,55 @@ func TestDbfsBasic(t *testing.T) { stat, err = f.Stat() assert.NoError(t, err) assert.EqualValues(t, 10, stat.Size()) + + t.Run("NonExisting", func(t *testing.T) { + f, err := OpenFile(t.Context(), "non-existing.txt", os.O_RDONLY) + assert.ErrorIs(t, err, os.ErrNotExist) + assert.Nil(t, f) + + f, err = OpenFile(t.Context(), "non-existing.txt", os.O_WRONLY) + assert.ErrorIs(t, err, os.ErrNotExist) + assert.Nil(t, f) + + f, err = OpenFile(t.Context(), "non-existing.txt", os.O_WRONLY|os.O_APPEND|os.O_TRUNC) + assert.ErrorIs(t, err, os.ErrNotExist) + assert.Nil(t, f) + }) + + t.Run("Existing", func(t *testing.T) { + assertFileContent := func(f File, expected string) { + _, err := f.Seek(0, io.SeekStart) + require.NoError(t, err) + buf, err := io.ReadAll(f) + require.NoError(t, err) + assert.Equal(t, expected, string(buf)) + } + + f, err := OpenFile(t.Context(), "existing.txt", os.O_RDWR|os.O_CREATE) + require.NoError(t, err) + _, _ = f.Write([]byte("test")) + assertFileContent(f, "test") + assert.NoError(t, f.Close()) + + f, err = OpenFile(t.Context(), "existing.txt", os.O_RDWR|os.O_CREATE|os.O_APPEND) + require.NoError(t, err) + _, _ = f.Write([]byte("\nnew")) + assertFileContent(f, "test\nnew") + assert.NoError(t, f.Close()) + + f, err = OpenFile(t.Context(), "existing.txt", os.O_RDWR|os.O_TRUNC) + require.NoError(t, err) + assertFileContent(f, "") + assert.NoError(t, f.Close()) + + f, err = OpenFile(t.Context(), "existing.txt", os.O_RDWR|os.O_CREATE|os.O_EXCL) + assert.ErrorIs(t, err, os.ErrExist) + assert.Nil(t, f) + }) } func TestDbfsReadWrite(t *testing.T) { - defer changeDefaultFileBlockSize(4)() + defer test.MockVariableValue(&defaultFileBlockSize, 4)() f1, err := OpenFile(t.Context(), "test.log", os.O_RDWR|os.O_CREATE) assert.NoError(t, err) @@ -157,30 +197,32 @@ func TestDbfsReadWrite(t *testing.T) { } func TestDbfsSeekWrite(t *testing.T) { - defer changeDefaultFileBlockSize(4)() + defer test.MockVariableValue(&defaultFileBlockSize, 4)() - f, err := OpenFile(t.Context(), "test2.log", os.O_RDWR|os.O_CREATE) - assert.NoError(t, err) - defer f.Close() + // write something + fw, err := OpenFile(t.Context(), "test2.log", os.O_RDWR|os.O_CREATE) + require.NoError(t, err) + defer fw.Close() - n, err := f.Write([]byte("111")) + n, err := fw.Write([]byte("111")) assert.NoError(t, err) - _, err = f.Seek(int64(n), io.SeekStart) + _, err = fw.Seek(int64(n), io.SeekStart) assert.NoError(t, err) - _, err = f.Write([]byte("222")) + _, err = fw.Write([]byte("222")) assert.NoError(t, err) - _, err = f.Seek(int64(n), io.SeekStart) + _, err = fw.Seek(int64(n), io.SeekStart) assert.NoError(t, err) - _, err = f.Write([]byte("333")) + _, err = fw.Write([]byte("333")) assert.NoError(t, err) + // then read it fr, err := OpenFile(t.Context(), "test2.log", os.O_RDONLY) - assert.NoError(t, err) - defer f.Close() + require.NoError(t, err) + defer fr.Close() buf, err := io.ReadAll(fr) assert.NoError(t, err) diff --git a/models/fixtures/action_run.yml b/models/fixtures/action_run.yml index 09dfa6cccbb..ac5e8303c35 100644 --- a/models/fixtures/action_run.yml +++ b/models/fixtures/action_run.yml @@ -139,3 +139,24 @@ updated: 1683636626 need_approval: 0 approved_by: 0 + +- + id: 805 + title: "update actions" + repo_id: 4 + owner_id: 1 + workflow_id: "artifact.yaml" + index: 191 + trigger_user_id: 1 + ref: "refs/heads/master" + commit_sha: "c2d72f548424103f01ee1dc02889c1e2bff816b0" + event: "push" + trigger_event: "push" + is_fork_pull_request: 0 + status: 5 + started: 1683636528 + stopped: 1683636626 + created: 1683636108 + updated: 1683636626 + need_approval: 0 + approved_by: 0 diff --git a/models/fixtures/action_run_job.yml b/models/fixtures/action_run_job.yml index 6c06d94aa44..04799b73ca0 100644 --- a/models/fixtures/action_run_job.yml +++ b/models/fixtures/action_run_job.yml @@ -129,3 +129,18 @@ status: 5 started: 1683636528 stopped: 1683636626 + +- + id: 206 + run_id: 805 + repo_id: 4 + owner_id: 1 + commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0 + is_fork_pull_request: 0 + name: job_2 + attempt: 1 + job_id: job_2 + task_id: 56 + status: 3 + started: 1683636528 + stopped: 1683636626 diff --git a/models/fixtures/action_task.yml b/models/fixtures/action_task.yml index c79fb070506..e1bc588dc59 100644 --- a/models/fixtures/action_task.yml +++ b/models/fixtures/action_task.yml @@ -177,3 +177,23 @@ log_length: 0 log_size: 0 log_expired: 0 + +- + id: 56 + attempt: 1 + runner_id: 1 + status: 3 # 3 is the status code for "cancelled" + started: 1683636528 + stopped: 1683636626 + repo_id: 4 + owner_id: 1 + commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0 + is_fork_pull_request: 0 + token_hash: 6d8ef48297195edcc8e22c70b3020eaa06c52976db67d39b4240c64a69a2cc1508825121b7b8394e48e00b1bf3718b2aaaab + token_salt: eeeeeeee + token_last_eight: eeeeeeee + log_filename: artifact-test2/2f/47.log + log_in_storage: 1 + log_length: 707 + log_size: 90179 + log_expired: 0 diff --git a/models/fixtures/attachment.yml b/models/fixtures/attachment.yml index 7882d8bff20..570d4a27daf 100644 --- a/models/fixtures/attachment.yml +++ b/models/fixtures/attachment.yml @@ -153,3 +153,16 @@ download_count: 0 size: 0 created_unix: 946684800 + +- + id: 13 + uuid: a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a23 + repo_id: 1 + issue_id: 0 + release_id: 4 + uploader_id: 2 + comment_id: 0 + name: draft-attach + download_count: 0 + size: 0 + created_unix: 946684800 diff --git a/models/fixtures/branch.yml b/models/fixtures/branch.yml index 03e21d04b45..a17999091e0 100644 --- a/models/fixtures/branch.yml +++ b/models/fixtures/branch.yml @@ -213,3 +213,39 @@ is_deleted: false deleted_by_id: 0 deleted_unix: 0 + +- + id: 26 + repo_id: 10 + name: 'feature/1' + commit_id: '65f1bf27bc3bf70f64657658635e66094edbcb4d' + commit_message: 'Initial commit' + commit_time: 1489950479 + pusher_id: 2 + is_deleted: false + deleted_by_id: 0 + deleted_unix: 0 + +- + id: 27 + repo_id: 1 + name: 'DefaultBranch' + commit_id: '90c1019714259b24fb81711d4416ac0f18667dfa' + commit_message: 'add license' + commit_time: 1709345946 + pusher_id: 1 + is_deleted: false + deleted_by_id: 0 + deleted_unix: 0 + +- + id: 28 + repo_id: 1 + name: 'sub-home-md-img-check' + commit_id: '4649299398e4d39a5c09eb4f534df6f1e1eb87cc' + commit_message: "Test how READMEs render images when found in a subfolder" + commit_time: 1678403550 + pusher_id: 1 + is_deleted: false + deleted_by_id: 0 + deleted_unix: 0 diff --git a/models/fixtures/repo_unit.yml b/models/fixtures/repo_unit.yml index f6b6252da1f..4c3e37500f0 100644 --- a/models/fixtures/repo_unit.yml +++ b/models/fixtures/repo_unit.yml @@ -733,3 +733,17 @@ type: 3 config: "{\"IgnoreWhitespaceConflicts\":false,\"AllowMerge\":true,\"AllowRebase\":true,\"AllowRebaseMerge\":true,\"AllowSquash\":true}" created_unix: 946684810 + +- + id: 111 + repo_id: 3 + type: 10 + config: "{}" + created_unix: 946684810 + +- + id: 112 + repo_id: 4 + type: 10 + config: "{}" + created_unix: 946684810 diff --git a/models/fixtures/repository.yml b/models/fixtures/repository.yml index 552a78cbd27..dfa514db37f 100644 --- a/models/fixtures/repository.yml +++ b/models/fixtures/repository.yml @@ -110,6 +110,8 @@ num_closed_milestones: 0 num_projects: 0 num_closed_projects: 1 + num_action_runs: 4 + num_closed_action_runs: 3 is_private: false is_empty: false is_archived: false diff --git a/models/git/branch.go b/models/git/branch.go index 54351649cc5..698c43f1472 100644 --- a/models/git/branch.go +++ b/models/git/branch.go @@ -247,7 +247,7 @@ func DeleteBranches(ctx context.Context, repoID, doerID int64, branchIDs []int64 return err } for _, branch := range branches { - if err := AddDeletedBranch(ctx, repoID, branch.Name, doerID); err != nil { + if err := MarkBranchAsDeleted(ctx, repoID, branch.Name, doerID); err != nil { return err } } @@ -268,8 +268,8 @@ func UpdateBranch(ctx context.Context, repoID, pusherID int64, branchName string }) } -// AddDeletedBranch adds a deleted branch to the database -func AddDeletedBranch(ctx context.Context, repoID int64, branchName string, deletedByID int64) error { +// MarkBranchAsDeleted marks branch as deleted +func MarkBranchAsDeleted(ctx context.Context, repoID int64, branchName string, deletedByID int64) error { branch, err := GetBranch(ctx, repoID, branchName) if err != nil { return err @@ -368,7 +368,7 @@ func RenameBranch(ctx context.Context, repo *repo_model.Repository, from, to str } // 1. update branch in database - if n, err := sess.Where("repo_id=? AND name=?", repo.ID, from).Update(&Branch{ + if n, err := sess.Where("repo_id=? AND name=?", repo.ID, from).Cols("name").Update(&Branch{ Name: to, }); err != nil { return err @@ -397,10 +397,16 @@ func RenameBranch(ctx context.Context, repo *repo_model.Repository, from, to str if protectedBranch != nil { // there is a protect rule for this branch - protectedBranch.RuleName = to - if _, err = sess.ID(protectedBranch.ID).Cols("branch_name").Update(protectedBranch); err != nil { + existingRule, err := GetProtectedBranchRuleByName(ctx, repo.ID, to) + if err != nil { return err } + if existingRule == nil || existingRule.ID == protectedBranch.ID { + protectedBranch.RuleName = to + if _, err = sess.ID(protectedBranch.ID).Cols("branch_name").Update(protectedBranch); err != nil { + return err + } + } } else { // some glob protect rules may match this branch protected, err := IsBranchProtected(ctx, repo.ID, from) @@ -444,7 +450,7 @@ func RenameBranch(ctx context.Context, repo *repo_model.Repository, from, to str type FindRecentlyPushedNewBranchesOptions struct { Repo *repo_model.Repository BaseRepo *repo_model.Repository - CommitAfterUnix int64 + PushedAfterUnix int64 MaxCount int } @@ -454,11 +460,11 @@ type RecentlyPushedNewBranch struct { BranchDisplayName string BranchLink string BranchCompareURL string - CommitTime timeutil.TimeStamp + PushedTime timeutil.TimeStamp } // FindRecentlyPushedNewBranches return at most 2 new branches pushed by the user in 2 hours which has no opened PRs created -// if opts.CommitAfterUnix is 0, we will find the branches that were committed to in the last 2 hours +// if opts.PushedAfterUnix is 0, we will find the branches that were pushed in the last 2 hours // if opts.ListOptions is not set, we will only display top 2 latest branches. // Protected branches will be skipped since they are unlikely to be used to create new PRs. func FindRecentlyPushedNewBranches(ctx context.Context, doer *user_model.User, opts FindRecentlyPushedNewBranchesOptions) ([]*RecentlyPushedNewBranch, error) { @@ -486,16 +492,29 @@ func FindRecentlyPushedNewBranches(ctx context.Context, doer *user_model.User, o } repoIDs := builder.Select("id").From("repository").Where(repoCond) - if opts.CommitAfterUnix == 0 { - opts.CommitAfterUnix = time.Now().Add(-time.Hour * 2).Unix() + if opts.PushedAfterUnix == 0 { + opts.PushedAfterUnix = time.Now().Add(-time.Hour * 2).Unix() } - baseBranch, err := GetBranch(ctx, opts.BaseRepo.ID, opts.BaseRepo.DefaultBranch) + var ignoredCommitIDs []string + baseDefaultBranch, err := GetBranch(ctx, opts.BaseRepo.ID, opts.BaseRepo.DefaultBranch) if err != nil { - return nil, err + log.Warn("GetBranch:DefaultBranch: %v", err) + } else { + ignoredCommitIDs = append(ignoredCommitIDs, baseDefaultBranch.CommitID) } - // find all related branches, these branches may already created PRs, we will check later + baseDefaultTargetBranchName := opts.BaseRepo.MustGetUnit(ctx, unit.TypePullRequests).PullRequestsConfig().DefaultTargetBranch + if baseDefaultTargetBranchName != "" && baseDefaultTargetBranchName != opts.BaseRepo.DefaultBranch { + baseDefaultTargetBranch, err := GetBranch(ctx, opts.BaseRepo.ID, baseDefaultTargetBranchName) + if err != nil { + log.Warn("GetBranch:DefaultTargetBranch: %v", err) + } else { + ignoredCommitIDs = append(ignoredCommitIDs, baseDefaultTargetBranch.CommitID) + } + } + + // find all related branches, these branches may already have PRs, we will check later var branches []*Branch if err := db.GetEngine(ctx). Where(builder.And( @@ -503,10 +522,10 @@ func FindRecentlyPushedNewBranches(ctx context.Context, doer *user_model.User, o "pusher_id": doer.ID, "is_deleted": false, }, - builder.Gte{"commit_time": opts.CommitAfterUnix}, + builder.Gte{"updated_unix": opts.PushedAfterUnix}, builder.In("repo_id", repoIDs), // newly created branch have no changes, so skip them - builder.Neq{"commit_id": baseBranch.CommitID}, + builder.NotIn("commit_id", ignoredCommitIDs), )). OrderBy(db.SearchOrderByRecentUpdated.String()). Find(&branches); err != nil { @@ -514,10 +533,8 @@ func FindRecentlyPushedNewBranches(ctx context.Context, doer *user_model.User, o } newBranches := make([]*RecentlyPushedNewBranch, 0, len(branches)) - if opts.MaxCount == 0 { - // by default we display 2 recently pushed new branch - opts.MaxCount = 2 - } + opts.MaxCount = util.IfZero(opts.MaxCount, 2) // by default, we display 2 recently pushed new branch + baseTargetBranchName := opts.BaseRepo.GetPullRequestTargetBranch(ctx) for _, branch := range branches { // whether the branch is protected protected, err := IsBranchProtected(ctx, branch.RepoID, branch.Name) @@ -555,8 +572,8 @@ func FindRecentlyPushedNewBranches(ctx context.Context, doer *user_model.User, o BranchDisplayName: branchDisplayName, BranchName: branch.Name, BranchLink: fmt.Sprintf("%s/src/branch/%s", branch.Repo.Link(), util.PathEscapeSegments(branch.Name)), - BranchCompareURL: branch.Repo.ComposeBranchCompareURL(opts.BaseRepo, branch.Name), - CommitTime: branch.CommitTime, + BranchCompareURL: branch.Repo.ComposeBranchCompareURL(opts.BaseRepo, baseTargetBranchName, branch.Name), + PushedTime: branch.UpdatedUnix, }) } if len(newBranches) == opts.MaxCount { @@ -566,3 +583,12 @@ func FindRecentlyPushedNewBranches(ctx context.Context, doer *user_model.User, o return newBranches, nil } + +// CountBranches returns the number of branches in the repository +func CountBranches(ctx context.Context, repoID int64, includeDeleted bool) (int64, error) { + sess := db.GetEngine(ctx).Where("repo_id=?", repoID) + if !includeDeleted { + sess.And("is_deleted=?", false) + } + return sess.Count(new(Branch)) +} diff --git a/models/git/branch_test.go b/models/git/branch_test.go index 5be435172b8..3832df93509 100644 --- a/models/git/branch_test.go +++ b/models/git/branch_test.go @@ -6,14 +6,17 @@ package git_test import ( "context" "testing" + "time" "code.gitea.io/gitea/models/db" git_model "code.gitea.io/gitea/models/git" issues_model "code.gitea.io/gitea/models/issues" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/timeutil" "github.com/stretchr/testify/assert" ) @@ -25,8 +28,8 @@ func TestAddDeletedBranch(t *testing.T) { firstBranch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{ID: 1}) assert.True(t, firstBranch.IsDeleted) - assert.NoError(t, git_model.AddDeletedBranch(t.Context(), repo.ID, firstBranch.Name, firstBranch.DeletedByID)) - assert.NoError(t, git_model.AddDeletedBranch(t.Context(), repo.ID, "branch2", int64(1))) + assert.NoError(t, git_model.MarkBranchAsDeleted(t.Context(), repo.ID, firstBranch.Name, firstBranch.DeletedByID)) + assert.NoError(t, git_model.MarkBranchAsDeleted(t.Context(), repo.ID, "branch2", int64(1))) secondBranch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo.ID, Name: "branch2"}) assert.True(t, secondBranch.IsDeleted) @@ -63,6 +66,36 @@ func TestGetDeletedBranch(t *testing.T) { assert.NotNil(t, getDeletedBranch(t, firstBranch)) } +func TestFindRecentlyPushedNewBranchesUsesPushTime(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10}) + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 12}) + branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo.ID, Name: "outdated-new-branch"}) + + commitUnix := time.Now().Add(-3 * time.Hour).Unix() + pushUnix := time.Now().Add(-30 * time.Minute).Unix() + _, err := db.GetEngine(t.Context()).Exec( + "UPDATE branch SET commit_time = ?, updated_unix = ? WHERE id = ?", + commitUnix, + pushUnix, + branch.ID, + ) + assert.NoError(t, err) + + branches, err := git_model.FindRecentlyPushedNewBranches(t.Context(), doer, git_model.FindRecentlyPushedNewBranchesOptions{ + Repo: repo, + BaseRepo: repo, + PushedAfterUnix: time.Now().Add(-time.Hour).Unix(), + MaxCount: 1, + }) + assert.NoError(t, err) + if assert.Len(t, branches, 1) { + assert.Equal(t, branch.Name, branches[0].BranchName) + assert.Equal(t, timeutil.TimeStamp(pushUnix), branches[0].PushedTime) + } +} + func TestDeletedBranchLoadUser(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) @@ -114,7 +147,7 @@ func TestFindRenamedBranch(t *testing.T) { assert.True(t, exist) assert.Equal(t, "master", branch.To) - _, exist, err = git_model.FindRenamedBranch(t.Context(), 1, "unknow") + _, exist, err = git_model.FindRenamedBranch(t.Context(), 1, "unknown") assert.NoError(t, err) assert.False(t, exist) } @@ -159,6 +192,53 @@ func TestRenameBranch(t *testing.T) { }) } +func TestRenameBranchProtectedRuleConflict(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + master := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo1.ID, Name: "master"}) + + devBranch := &git_model.Branch{ + RepoID: repo1.ID, + Name: "dev", + CommitID: master.CommitID, + CommitMessage: master.CommitMessage, + CommitTime: master.CommitTime, + PusherID: master.PusherID, + } + assert.NoError(t, db.Insert(t.Context(), devBranch)) + + pbDev := git_model.ProtectedBranch{ + RepoID: repo1.ID, + RuleName: "dev", + CanPush: true, + } + assert.NoError(t, git_model.UpdateProtectBranch(t.Context(), repo1, &pbDev, git_model.WhitelistOptions{})) + + pbMain := git_model.ProtectedBranch{ + RepoID: repo1.ID, + RuleName: "main", + CanPush: true, + } + assert.NoError(t, git_model.UpdateProtectBranch(t.Context(), repo1, &pbMain, git_model.WhitelistOptions{})) + + assert.NoError(t, git_model.RenameBranch(t.Context(), repo1, "dev", "main", func(ctx context.Context, isDefault bool) error { + return nil + })) + + unittest.AssertNotExistsBean(t, &git_model.Branch{RepoID: repo1.ID, Name: "dev"}) + unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo1.ID, Name: "main"}) + + protectedDev, err := git_model.GetProtectedBranchRuleByName(t.Context(), repo1.ID, "dev") + assert.NoError(t, err) + assert.NotNil(t, protectedDev) + assert.Equal(t, "dev", protectedDev.RuleName) + + protectedMainByID, err := git_model.GetProtectedBranchRuleByID(t.Context(), repo1.ID, pbMain.ID) + assert.NoError(t, err) + assert.NotNil(t, protectedMainByID) + assert.Equal(t, "main", protectedMainByID.RuleName) +} + func TestOnlyGetDeletedBranchOnCorrectRepo(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) @@ -183,3 +263,25 @@ func TestOnlyGetDeletedBranchOnCorrectRepo(t *testing.T) { assert.NoError(t, err) assert.NotNil(t, deletedBranch) } + +func TestCountBranches(t *testing.T) { + // 1. Setup - Exactly like TestAddDeletedBranch + assert.NoError(t, unittest.PrepareTestDatabase()) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + // 2. Execution - Using t.Context() to match the rest of the file + initialCount, err := git_model.CountBranches(t.Context(), repo.ID, false) + assert.NoError(t, err) + + // 3. Database Action - Using t.Context() + err = db.Insert(t.Context(), &git_model.Branch{ + RepoID: repo.ID, + Name: "test-branch-for-counting", + }) + assert.NoError(t, err) + + // 4. Verification + newCount, err := git_model.CountBranches(t.Context(), repo.ID, false) + assert.NoError(t, err) + assert.Equal(t, initialCount+1, newCount) +} diff --git a/models/git/commit_status.go b/models/git/commit_status.go index e255bca5d02..2ae5937a3d8 100644 --- a/models/git/commit_status.go +++ b/models/git/commit_status.go @@ -30,17 +30,21 @@ import ( // CommitStatus holds a single Status of a single Commit type CommitStatus struct { - ID int64 `xorm:"pk autoincr"` - Index int64 `xorm:"INDEX UNIQUE(repo_sha_index)"` - RepoID int64 `xorm:"INDEX UNIQUE(repo_sha_index)"` - Repo *repo_model.Repository `xorm:"-"` - State commitstatus.CommitStatusState `xorm:"VARCHAR(7) NOT NULL"` - SHA string `xorm:"VARCHAR(64) NOT NULL INDEX UNIQUE(repo_sha_index)"` - TargetURL string `xorm:"TEXT"` - Description string `xorm:"TEXT"` - ContextHash string `xorm:"VARCHAR(64) index"` - Context string `xorm:"TEXT"` - Creator *user_model.User `xorm:"-"` + ID int64 `xorm:"pk autoincr"` + Index int64 `xorm:"INDEX UNIQUE(repo_sha_index)"` + RepoID int64 `xorm:"INDEX UNIQUE(repo_sha_index)"` + Repo *repo_model.Repository `xorm:"-"` + State commitstatus.CommitStatusState `xorm:"VARCHAR(7) NOT NULL"` + SHA string `xorm:"VARCHAR(64) NOT NULL INDEX UNIQUE(repo_sha_index)"` + + // TargetURL points to the commit status page reported by a CI system + // If Gitea Actions is used, it is a relative link like "{RepoLink}/actions/runs/{RunID}/jobs{JobID}" + TargetURL string `xorm:"TEXT"` + + Description string `xorm:"TEXT"` + ContextHash string `xorm:"VARCHAR(64) index"` + Context string `xorm:"TEXT"` + Creator *user_model.User `xorm:"-"` CreatorID int64 CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` @@ -211,21 +215,45 @@ func (status *CommitStatus) LocaleString(lang translation.Locale) string { // HideActionsURL set `TargetURL` to an empty string if the status comes from Gitea Actions func (status *CommitStatus) HideActionsURL(ctx context.Context) { + if _, ok := status.cutTargetURLGiteaActionsPrefix(ctx); ok { + status.TargetURL = "" + } +} + +func (status *CommitStatus) cutTargetURLGiteaActionsPrefix(ctx context.Context) (string, bool) { if status.RepoID == 0 { - return + return "", false } if status.Repo == nil { if err := status.loadRepository(ctx); err != nil { log.Error("loadRepository: %v", err) - return + return "", false } } prefix := status.Repo.Link() + "/actions" - if strings.HasPrefix(status.TargetURL, prefix) { - status.TargetURL = "" + return strings.CutPrefix(status.TargetURL, prefix) +} + +// ParseGiteaActionsTargetURL parses the commit status target URL as Gitea Actions link +func (status *CommitStatus) ParseGiteaActionsTargetURL(ctx context.Context) (runID, jobID int64, ok bool) { + s, ok := status.cutTargetURLGiteaActionsPrefix(ctx) + if !ok { + return 0, 0, false } + + parts := strings.Split(s, "/") // expect: /runs/{runID}/jobs/{jobID} + if len(parts) < 5 || parts[1] != "runs" || parts[3] != "jobs" { + return 0, 0, false + } + + runID, err1 := strconv.ParseInt(parts[2], 10, 64) + jobID, err2 := strconv.ParseInt(parts[4], 10, 64) + if err1 != nil || err2 != nil { + return 0, 0, false + } + return runID, jobID, true } // CalcCommitStatus returns commit status state via some status, the commit statues should order by id desc diff --git a/models/git/commit_status_test.go b/models/git/commit_status_test.go index d1b9dfc3bf9..f565550c539 100644 --- a/models/git/commit_status_test.go +++ b/models/git/commit_status_test.go @@ -243,7 +243,7 @@ func TestCommitStatusesHideActionsURL(t *testing.T) { statuses := []*git_model.CommitStatus{ { RepoID: repo.ID, - TargetURL: fmt.Sprintf("%s/jobs/%d", run.Link(), run.Index), + TargetURL: fmt.Sprintf("%s/jobs/%d", run.Link(), run.ID), }, { RepoID: repo.ID, diff --git a/models/git/lfs.go b/models/git/lfs.go index 8bba060ff97..1f3d086ac25 100644 --- a/models/git/lfs.go +++ b/models/git/lfs.go @@ -8,7 +8,6 @@ import ( "fmt" "code.gitea.io/gitea/models/db" - "code.gitea.io/gitea/models/perm" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" @@ -42,30 +41,6 @@ func (err ErrLFSLockNotExist) Unwrap() error { return util.ErrNotExist } -// ErrLFSUnauthorizedAction represents a "LFSUnauthorizedAction" kind of error. -type ErrLFSUnauthorizedAction struct { - RepoID int64 - UserName string - Mode perm.AccessMode -} - -// IsErrLFSUnauthorizedAction checks if an error is a ErrLFSUnauthorizedAction. -func IsErrLFSUnauthorizedAction(err error) bool { - _, ok := err.(ErrLFSUnauthorizedAction) - return ok -} - -func (err ErrLFSUnauthorizedAction) Error() string { - if err.Mode == perm.AccessModeWrite { - return fmt.Sprintf("User %s doesn't have write access for lfs lock [rid: %d]", err.UserName, err.RepoID) - } - return fmt.Sprintf("User %s doesn't have read access for lfs lock [rid: %d]", err.UserName, err.RepoID) -} - -func (err ErrLFSUnauthorizedAction) Unwrap() error { - return util.ErrPermissionDenied -} - // ErrLFSLockAlreadyExist represents a "LFSLockAlreadyExist" kind of error. type ErrLFSLockAlreadyExist struct { RepoID int64 @@ -93,12 +68,6 @@ type ErrLFSFileLocked struct { UserName string } -// IsErrLFSFileLocked checks if an error is a ErrLFSFileLocked. -func IsErrLFSFileLocked(err error) bool { - _, ok := err.(ErrLFSFileLocked) - return ok -} - func (err ErrLFSFileLocked) Error() string { return fmt.Sprintf("File is lfs locked [repo: %d, locked by: %s, path: %s]", err.RepoID, err.UserName, err.Path) } @@ -343,15 +312,12 @@ func IterateRepositoryIDsWithLFSMetaObjects(ctx context.Context, f func(ctx cont // IterateLFSMetaObjectsForRepoOptions provides options for IterateLFSMetaObjectsForRepo type IterateLFSMetaObjectsForRepoOptions struct { - OlderThan timeutil.TimeStamp - UpdatedLessRecentlyThan timeutil.TimeStamp - OrderByUpdated bool - LoopFunctionAlwaysUpdates bool + OlderThan timeutil.TimeStamp + UpdatedLessRecentlyThan timeutil.TimeStamp } // IterateLFSMetaObjectsForRepo provides a iterator for LFSMetaObjects per Repo func IterateLFSMetaObjectsForRepo(ctx context.Context, repoID int64, f func(context.Context, *LFSMetaObject, int64) error, opts *IterateLFSMetaObjectsForRepoOptions) error { - var start int batchSize := setting.Database.IterateBufferSize engine := db.GetEngine(ctx) type CountLFSMetaObject struct { @@ -359,7 +325,7 @@ func IterateLFSMetaObjectsForRepo(ctx context.Context, repoID int64, f func(cont LFSMetaObject `xorm:"extends"` } - id := int64(0) + lastID := int64(0) for { beans := make([]*CountLFSMetaObject, 0, batchSize) @@ -372,29 +338,23 @@ func IterateLFSMetaObjectsForRepo(ctx context.Context, repoID int64, f func(cont if !opts.UpdatedLessRecentlyThan.IsZero() { sess.And("`lfs_meta_object`.updated_unix < ?", opts.UpdatedLessRecentlyThan) } - sess.GroupBy("`lfs_meta_object`.id") - if opts.OrderByUpdated { - sess.OrderBy("`lfs_meta_object`.updated_unix ASC") - } else { - sess.And("`lfs_meta_object`.id > ?", id) - sess.OrderBy("`lfs_meta_object`.id ASC") - } - if err := sess.Limit(batchSize, start).Find(&beans); err != nil { + sess.GroupBy("`lfs_meta_object`.id"). + And("`lfs_meta_object`.id > ?", lastID). + OrderBy("`lfs_meta_object`.id ASC") + + if err := sess.Limit(batchSize).Find(&beans); err != nil { return err } if len(beans) == 0 { return nil } - if !opts.LoopFunctionAlwaysUpdates { - start += len(beans) - } for _, bean := range beans { if err := f(ctx, &bean.LFSMetaObject, bean.Count); err != nil { return err } } - id = beans[len(beans)-1].ID + lastID = beans[len(beans)-1].ID } } diff --git a/models/git/lfs_lock.go b/models/git/lfs_lock.go index c5f9a4e6dec..8e63598fb28 100644 --- a/models/git/lfs_lock.go +++ b/models/git/lfs_lock.go @@ -11,10 +11,7 @@ import ( "time" "code.gitea.io/gitea/models/db" - "code.gitea.io/gitea/models/perm" - access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" - "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" @@ -71,10 +68,6 @@ func (l *LFSLock) LoadOwner(ctx context.Context) error { // CreateLFSLock creates a new lock. func CreateLFSLock(ctx context.Context, repo *repo_model.Repository, lock *LFSLock) (*LFSLock, error) { return db.WithTx2(ctx, func(ctx context.Context) (*LFSLock, error) { - if err := CheckLFSAccessForRepo(ctx, lock.OwnerID, repo, perm.AccessModeWrite); err != nil { - return nil, err - } - lock.Path = util.PathJoinRel(lock.Path) lock.RepoID = repo.ID @@ -108,10 +101,10 @@ func GetLFSLock(ctx context.Context, repo *repo_model.Repository, path string) ( return rel, nil } -// GetLFSLockByID returns release by given id. -func GetLFSLockByID(ctx context.Context, id int64) (*LFSLock, error) { +// GetLFSLockByIDAndRepo returns lfs lock by given id and repository id. +func GetLFSLockByIDAndRepo(ctx context.Context, id, repoID int64) (*LFSLock, error) { lock := new(LFSLock) - has, err := db.GetEngine(ctx).ID(id).Get(lock) + has, err := db.GetEngine(ctx).ID(id).And("repo_id = ?", repoID).Get(lock) if err != nil { return nil, err } else if !has { @@ -137,7 +130,7 @@ func GetLFSLockByRepoID(ctx context.Context, repoID int64, page, pageSize int) ( // GetTreePathLock returns LSF lock for the treePath func GetTreePathLock(ctx context.Context, repoID int64, treePath string) (*LFSLock, error) { if !setting.LFS.StartServer { - return nil, nil + return nil, nil //nolint:nilnil // return nil when LFS is not started } locks, err := GetLFSLockByRepoID(ctx, repoID, 0, 0) @@ -149,7 +142,7 @@ func GetTreePathLock(ctx context.Context, repoID int64, treePath string) (*LFSLo return lock, nil } } - return nil, nil + return nil, nil //nolint:nilnil // return nil to indicate that the object does not exist } // CountLFSLockByRepoID returns a count of all LFSLocks associated with a repository. @@ -160,15 +153,11 @@ func CountLFSLockByRepoID(ctx context.Context, repoID int64) (int64, error) { // DeleteLFSLockByID deletes a lock by given ID. func DeleteLFSLockByID(ctx context.Context, id int64, repo *repo_model.Repository, u *user_model.User, force bool) (*LFSLock, error) { return db.WithTx2(ctx, func(ctx context.Context) (*LFSLock, error) { - lock, err := GetLFSLockByID(ctx, id) + lock, err := GetLFSLockByIDAndRepo(ctx, id, repo.ID) if err != nil { return nil, err } - if err := CheckLFSAccessForRepo(ctx, u.ID, repo, perm.AccessModeWrite); err != nil { - return nil, err - } - if !force && u.ID != lock.OwnerID { return nil, errors.New("user doesn't own lock and force flag is not set") } @@ -180,22 +169,3 @@ func DeleteLFSLockByID(ctx context.Context, id int64, repo *repo_model.Repositor return lock, nil }) } - -// CheckLFSAccessForRepo check needed access mode base on action -func CheckLFSAccessForRepo(ctx context.Context, ownerID int64, repo *repo_model.Repository, mode perm.AccessMode) error { - if ownerID == 0 { - return ErrLFSUnauthorizedAction{repo.ID, "undefined", mode} - } - u, err := user_model.GetUserByID(ctx, ownerID) - if err != nil { - return err - } - perm, err := access_model.GetUserRepoPermission(ctx, repo, u) - if err != nil { - return err - } - if !perm.CanAccess(mode, unit.TypeCode) { - return ErrLFSUnauthorizedAction{repo.ID, u.DisplayName(), mode} - } - return nil -} diff --git a/models/git/lfs_lock_test.go b/models/git/lfs_lock_test.go new file mode 100644 index 00000000000..c88e89be473 --- /dev/null +++ b/models/git/lfs_lock_test.go @@ -0,0 +1,82 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "fmt" + "testing" + "time" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func createTestLock(t *testing.T, repo *repo_model.Repository, owner *user_model.User) *LFSLock { + t.Helper() + + path := fmt.Sprintf("%s-%d-%d", t.Name(), repo.ID, time.Now().UnixNano()) + lock, err := CreateLFSLock(t.Context(), repo, &LFSLock{ + OwnerID: owner.ID, + Path: path, + }) + require.NoError(t, err) + return lock +} + +func TestGetLFSLockByIDAndRepo(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + + lockRepo1 := createTestLock(t, repo1, user2) + lockRepo3 := createTestLock(t, repo3, user4) + + fetched, err := GetLFSLockByIDAndRepo(t.Context(), lockRepo1.ID, repo1.ID) + require.NoError(t, err) + assert.Equal(t, lockRepo1.ID, fetched.ID) + assert.Equal(t, repo1.ID, fetched.RepoID) + + _, err = GetLFSLockByIDAndRepo(t.Context(), lockRepo1.ID, repo3.ID) + assert.Error(t, err) + assert.True(t, IsErrLFSLockNotExist(err)) + + _, err = GetLFSLockByIDAndRepo(t.Context(), lockRepo3.ID, repo1.ID) + assert.Error(t, err) + assert.True(t, IsErrLFSLockNotExist(err)) +} + +func TestDeleteLFSLockByIDRequiresRepoMatch(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + + lockRepo1 := createTestLock(t, repo1, user2) + lockRepo3 := createTestLock(t, repo3, user4) + + _, err := DeleteLFSLockByID(t.Context(), lockRepo3.ID, repo1, user2, true) + assert.Error(t, err) + assert.True(t, IsErrLFSLockNotExist(err)) + + existing, err := GetLFSLockByIDAndRepo(t.Context(), lockRepo3.ID, repo3.ID) + require.NoError(t, err) + assert.Equal(t, lockRepo3.ID, existing.ID) + + deleted, err := DeleteLFSLockByID(t.Context(), lockRepo3.ID, repo3, user4, true) + require.NoError(t, err) + assert.Equal(t, lockRepo3.ID, deleted.ID) + + deleted, err = DeleteLFSLockByID(t.Context(), lockRepo1.ID, repo1, user2, false) + require.NoError(t, err) + assert.Equal(t, lockRepo1.ID, deleted.ID) +} diff --git a/models/git/lfs_test.go b/models/git/lfs_test.go new file mode 100644 index 00000000000..4c0242f439c --- /dev/null +++ b/models/git/lfs_test.go @@ -0,0 +1,61 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git_test + +import ( + "bytes" + "context" + "strconv" + "testing" + "time" + + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/lfs" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/modules/timeutil" + + "github.com/stretchr/testify/assert" +) + +func TestIterateLFSMetaObjectsForRepoUpdatesDoNotSkip(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + ctx := t.Context() + repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, "user2", "repo1") + assert.NoError(t, err) + + defer test.MockVariableValue(&setting.Database.IterateBufferSize, 1)() + + created := make([]*git_model.LFSMetaObject, 0, 3) + for i := range 3 { + content := []byte("gitea-lfs-" + strconv.Itoa(i)) + pointer, err := lfs.GeneratePointer(bytes.NewReader(content)) + assert.NoError(t, err) + + meta, err := git_model.NewLFSMetaObject(ctx, repo.ID, pointer) + assert.NoError(t, err) + created = append(created, meta) + } + + iterated := make([]int64, 0, len(created)) + cutoff := time.Now().Add(24 * time.Hour) + iterErr := git_model.IterateLFSMetaObjectsForRepo(ctx, repo.ID, func(ctx context.Context, meta *git_model.LFSMetaObject, count int64) error { + iterated = append(iterated, meta.ID) + _, err := db.GetEngine(ctx).ID(meta.ID).Cols("updated_unix").Update(&git_model.LFSMetaObject{ + UpdatedUnix: timeutil.TimeStamp(time.Now().Unix()), + }) + return err + }, &git_model.IterateLFSMetaObjectsForRepoOptions{ + OlderThan: timeutil.TimeStamp(cutoff.Unix()), + UpdatedLessRecentlyThan: timeutil.TimeStamp(cutoff.Unix()), + }) + assert.NoError(t, iterErr) + + expected := []int64{created[0].ID, created[1].ID, created[2].ID} + assert.Equal(t, expected, iterated) +} diff --git a/models/git/protected_branch.go b/models/git/protected_branch.go index 511f7563cf5..f4567a0aac9 100644 --- a/models/git/protected_branch.go +++ b/models/git/protected_branch.go @@ -5,7 +5,6 @@ package git import ( "context" - "errors" "fmt" "slices" "strings" @@ -25,7 +24,7 @@ import ( "xorm.io/builder" ) -var ErrBranchIsProtected = errors.New("branch is protected") +var ErrBranchIsProtected = util.ErrorWrap(util.ErrPermissionDenied, "branch is protected") // ProtectedBranch struct type ProtectedBranch struct { @@ -319,7 +318,7 @@ func GetProtectedBranchRuleByName(ctx context.Context, repoID int64, ruleName st if err != nil { return nil, err } else if !exist { - return nil, nil + return nil, nil //nolint:nilnil // return nil to indicate that the object does not exist } return rel, nil } @@ -330,7 +329,7 @@ func GetProtectedBranchRuleByID(ctx context.Context, repoID, ruleID int64) (*Pro if err != nil { return nil, err } else if !exist { - return nil, nil + return nil, nil //nolint:nilnil // return nil to indicate that the object does not exist } return rel, nil } @@ -467,11 +466,13 @@ func updateApprovalWhitelist(ctx context.Context, repo *repo_model.Repository, c return currentWhitelist, nil } + prUserIDs, err := access_model.GetUserIDsWithUnitAccess(ctx, repo, perm.AccessModeRead, unit.TypePullRequests) + if err != nil { + return nil, err + } whitelist = make([]int64, 0, len(newWhitelist)) for _, userID := range newWhitelist { - if reader, err := access_model.IsRepoReader(ctx, repo, userID); err != nil { - return nil, err - } else if !reader { + if !prUserIDs.Contains(userID) { continue } whitelist = append(whitelist, userID) diff --git a/models/git/protected_tag.go b/models/git/protected_tag.go index 95642df5932..dc38daf9815 100644 --- a/models/git/protected_tag.go +++ b/models/git/protected_tag.go @@ -104,7 +104,7 @@ func GetProtectedTagByID(ctx context.Context, id int64) (*ProtectedTag, error) { return nil, err } if !has { - return nil, nil + return nil, nil //nolint:nilnil // return nil to indicate that the object does not exist } return tag, nil } @@ -117,7 +117,7 @@ func GetProtectedTagByNamePattern(ctx context.Context, repoID int64, pattern str return nil, err } if !has { - return nil, nil + return nil, nil //nolint:nilnil // return nil to indicate that the object does not exist } return tag, nil } diff --git a/models/issues/comment.go b/models/issues/comment.go index 3a4049700de..25e74c01eab 100644 --- a/models/issues/comment.go +++ b/models/issues/comment.go @@ -20,6 +20,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/htmlutil" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/references" @@ -233,11 +234,17 @@ func (r RoleInRepo) LocaleHelper(lang translation.Locale) string { return lang.TrString("repo.issues.role." + string(r) + "_helper") } +type SpecialDoerNameType string + +const SpecialDoerNameCodeOwners SpecialDoerNameType = "CODEOWNERS" + // CommentMetaData stores metadata for a comment, these data will not be changed once inserted into database type CommentMetaData struct { ProjectColumnID int64 `json:"project_column_id,omitempty"` ProjectColumnTitle string `json:"project_column_title,omitempty"` ProjectTitle string `json:"project_title,omitempty"` + + SpecialDoerName SpecialDoerNameType `json:"special_doer_name,omitempty"` // e.g. "CODEOWNERS" for CODEOWNERS-triggered review requests } // Comment represents a comment in commit and issue page. @@ -692,7 +699,7 @@ func (c *Comment) LoadTime(ctx context.Context) error { return nil } var err error - c.Time, err = GetTrackedTimeByID(ctx, c.TimeID) + c.Time, err = GetTrackedTimeByID(ctx, c.IssueID, c.TimeID) return err } @@ -764,6 +771,37 @@ func (c *Comment) CodeCommentLink(ctx context.Context) string { return fmt.Sprintf("%s/files#%s", c.Issue.Link(), c.HashTag()) } +func (c *Comment) MetaSpecialDoerTr(locale translation.Locale) template.HTML { + if c.CommentMetaData == nil { + return "" + } + if c.CommentMetaData.SpecialDoerName == SpecialDoerNameCodeOwners { + return locale.Tr("repo.issues.review.codeowners_rules") + } + return htmlutil.HTMLFormat("%s", c.CommentMetaData.SpecialDoerName) +} + +func (c *Comment) TimelineRequestedReviewTr(locale translation.Locale, createdStr template.HTML) template.HTML { + if c.AssigneeID > 0 { + // it guarantees LoadAssigneeUserAndTeam has been called, and c.Assignee is Ghost user but not nil if the user doesn't exist + if c.RemovedAssignee { + if c.PosterID == c.AssigneeID { + return locale.Tr("repo.issues.review.remove_review_request_self", createdStr) + } + return locale.Tr("repo.issues.review.remove_review_request", c.Assignee.GetDisplayName(), createdStr) + } + return locale.Tr("repo.issues.review.add_review_request", c.Assignee.GetDisplayName(), createdStr) + } + teamName := "Ghost Team" + if c.AssigneeTeam != nil { + teamName = c.AssigneeTeam.Name + } + if c.RemovedAssignee { + return locale.Tr("repo.issues.review.remove_review_request", teamName, createdStr) + } + return locale.Tr("repo.issues.review.add_review_request", teamName, createdStr) +} + // CreateComment creates comment with context func CreateComment(ctx context.Context, opts *CreateCommentOptions) (_ *Comment, err error) { return db.WithTx2(ctx, func(ctx context.Context) (*Comment, error) { @@ -780,6 +818,11 @@ func CreateComment(ctx context.Context, opts *CreateCommentOptions) (_ *Comment, ProjectTitle: opts.ProjectTitle, } } + if opts.SpecialDoerName != "" { + commentMetaData = &CommentMetaData{ + SpecialDoerName: opts.SpecialDoerName, + } + } comment := &Comment{ Type: opts.Type, @@ -862,10 +905,7 @@ func updateCommentInfos(ctx context.Context, opts *CreateCommentOptions, comment if err = UpdateCommentAttachments(ctx, comment, opts.Attachments); err != nil { return err } - case CommentTypeReopen, CommentTypeClose: - if err = repo_model.UpdateRepoIssueNumbers(ctx, opts.Issue.RepoID, opts.Issue.IsPull, true); err != nil { - return err - } + // comment type reopen and close event have their own logic to update numbers but not here } // update the issue's updated_unix column return UpdateIssueCols(ctx, opts.Issue, "updated_unix") @@ -979,6 +1019,7 @@ type CreateCommentOptions struct { RefIsPull bool IsForcePush bool Invalidated bool + SpecialDoerName SpecialDoerNameType // e.g. "CODEOWNERS" for CODEOWNERS-triggered review requests } // GetCommentByID returns the comment by given ID. @@ -993,6 +1034,20 @@ func GetCommentByID(ctx context.Context, id int64) (*Comment, error) { return c, nil } +func GetCommentWithRepoID(ctx context.Context, repoID, commentID int64) (*Comment, error) { + c, err := GetCommentByID(ctx, commentID) + if err != nil { + return nil, err + } + if err := c.LoadIssue(ctx); err != nil { + return nil, err + } + if c.Issue.RepoID != repoID { + return nil, ErrCommentNotExist{commentID, 0} + } + return c, nil +} + // FindCommentsOptions describes the conditions to Find comments type FindCommentsOptions struct { db.ListOptions diff --git a/models/issues/comment_code.go b/models/issues/comment_code.go index 55e67a1243b..8de52f815a4 100644 --- a/models/issues/comment_code.go +++ b/models/issues/comment_code.go @@ -102,6 +102,7 @@ func findCodeComments(ctx context.Context, opts FindCommentsOptions, issue *Issu continue } comment.Review = re + comment.Issue = issue } comments[n] = comment n++ diff --git a/models/issues/issue.go b/models/issues/issue.go index 053b96dceb5..655cdebdfc6 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -498,7 +498,7 @@ func (issue *Issue) GetLastComment(ctx context.Context) (*Comment, error) { return nil, err } if !exist { - return nil, nil + return nil, nil //nolint:nilnil // return nil to indicate that the object does not exist } return &c, nil } @@ -682,7 +682,7 @@ func (issue *Issue) GetParticipantIDsByIssue(ctx context.Context) ([]int64, erro } // BlockedByDependencies finds all Dependencies an issue is blocked by -func (issue *Issue) BlockedByDependencies(ctx context.Context, opts db.ListOptions) (issueDeps []*DependencyInfo, err error) { +func (issue *Issue) BlockedByDependencies(ctx context.Context, opts db.ListOptions) (issueDeps []*DependencyInfo, total int64, err error) { sess := db.GetEngine(ctx). Table("issue"). Join("INNER", "repository", "repository.id = issue.repo_id"). @@ -693,13 +693,13 @@ func (issue *Issue) BlockedByDependencies(ctx context.Context, opts db.ListOptio if opts.Page > 0 { sess = db.SetSessionPagination(sess, &opts) } - err = sess.Find(&issueDeps) + total, err = sess.FindAndCount(&issueDeps) for _, depInfo := range issueDeps { depInfo.Issue.Repo = &depInfo.Repository } - return issueDeps, err + return issueDeps, total, err } // BlockingDependencies returns all blocking dependencies, aka all other issues a given issue blocks diff --git a/models/issues/issue_search.go b/models/issues/issue_search.go index 466e788d6c4..049dcc7de8a 100644 --- a/models/issues/issue_search.go +++ b/models/issues/issue_search.go @@ -476,7 +476,7 @@ func applySubscribedCondition(sess *xorm.Session, subscriberID int64) { ), builder.Eq{"issue.poster_id": subscriberID}, builder.In("issue.repo_id", builder. - Select("id"). + Select("repo_id"). From("watch"). Where(builder.And(builder.Eq{"user_id": subscriberID}, builder.In("mode", repo_model.WatchModeNormal, repo_model.WatchModeAuto))), diff --git a/models/issues/issue_test.go b/models/issues/issue_test.go index 09fd4926673..55a90f50a19 100644 --- a/models/issues/issue_test.go +++ b/models/issues/issue_test.go @@ -197,6 +197,12 @@ func TestIssues(t *testing.T) { }, []int64{2}, }, + { + issues_model.IssuesOptions{ + SubscriberID: 11, + }, + []int64{11, 5, 9, 8, 3, 2, 1}, + }, } { issues, err := issues_model.Issues(t.Context(), &test.Opts) assert.NoError(t, err) diff --git a/models/issues/issue_update.go b/models/issues/issue_update.go index 553e99aece2..0a320ffc56f 100644 --- a/models/issues/issue_update.go +++ b/models/issues/issue_update.go @@ -146,8 +146,19 @@ func updateIssueNumbers(ctx context.Context, issue *Issue, doer *user_model.User } // update repository's issue closed number - if err := repo_model.UpdateRepoIssueNumbers(ctx, issue.RepoID, issue.IsPull, true); err != nil { - return nil, err + switch cmtType { + case CommentTypeClose, CommentTypeMergePull: + // only increase closed count + if err := IncrRepoIssueNumbers(ctx, issue.RepoID, issue.IsPull, false); err != nil { + return nil, err + } + case CommentTypeReopen: + // only decrease closed count + if err := DecrRepoIssueNumbers(ctx, issue.RepoID, issue.IsPull, false, true); err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("invalid comment type: %d", cmtType) } return CreateComment(ctx, &CreateCommentOptions{ @@ -318,7 +329,6 @@ type NewIssueOptions struct { Issue *Issue LabelIDs []int64 Attachments []string // In UUID format. - IsPull bool } // NewIssueWithIndex creates issue with given index @@ -369,7 +379,8 @@ func NewIssueWithIndex(ctx context.Context, doer *user_model.User, opts NewIssue } } - if err := repo_model.UpdateRepoIssueNumbers(ctx, opts.Issue.RepoID, opts.IsPull, false); err != nil { + // Update repository issue total count + if err := IncrRepoIssueNumbers(ctx, opts.Repo.ID, opts.Issue.IsPull, true); err != nil { return err } @@ -439,6 +450,42 @@ func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *Issue, la }) } +// IncrRepoIssueNumbers increments repository issue numbers. +func IncrRepoIssueNumbers(ctx context.Context, repoID int64, isPull, totalOrClosed bool) error { + dbSession := db.GetEngine(ctx) + var colName string + if totalOrClosed { + colName = util.Iif(isPull, "num_pulls", "num_issues") + } else { + colName = util.Iif(isPull, "num_closed_pulls", "num_closed_issues") + } + _, err := dbSession.Incr(colName).ID(repoID). + NoAutoCondition().NoAutoTime(). + Update(new(repo_model.Repository)) + return err +} + +// DecrRepoIssueNumbers decrements repository issue numbers. +func DecrRepoIssueNumbers(ctx context.Context, repoID int64, isPull, includeTotal, includeClosed bool) error { + if !includeTotal && !includeClosed { + return fmt.Errorf("no numbers to decrease for repo id %d", repoID) + } + + dbSession := db.GetEngine(ctx) + if includeTotal { + colName := util.Iif(isPull, "num_pulls", "num_issues") + dbSession = dbSession.Decr(colName) + } + if includeClosed { + closedColName := util.Iif(isPull, "num_closed_pulls", "num_closed_issues") + dbSession = dbSession.Decr(closedColName) + } + _, err := dbSession.ID(repoID). + NoAutoCondition().NoAutoTime(). + Update(new(repo_model.Repository)) + return err +} + // UpdateIssueMentions updates issue-user relations for mentioned users. func UpdateIssueMentions(ctx context.Context, issueID int64, mentions []*user_model.User) error { if len(mentions) == 0 { diff --git a/models/issues/label_test.go b/models/issues/label_test.go index 50393855d97..42f774e7507 100644 --- a/models/issues/label_test.go +++ b/models/issues/label_test.go @@ -254,7 +254,7 @@ func TestGetLabelsByIssueID(t *testing.T) { func TestUpdateLabel(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1}) - // make sure update wont overwrite it + // make sure update won't overwrite it update := &issues_model.Label{ ID: label.ID, Color: "#ffff00", diff --git a/models/issues/milestone.go b/models/issues/milestone.go index 373f39f4ffe..1dd86302765 100644 --- a/models/issues/milestone.go +++ b/models/issues/milestone.go @@ -75,6 +75,8 @@ func init() { func (m *Milestone) BeforeUpdate() { if m.NumIssues > 0 { m.Completeness = m.NumClosedIssues * 100 / m.NumIssues + } else if m.IsClosed { + m.Completeness = 100 } else { m.Completeness = 0 } @@ -181,6 +183,7 @@ func updateMilestone(ctx context.Context, m *Milestone) error { func UpdateMilestoneCounters(ctx context.Context, id int64) error { e := db.GetEngine(ctx) _, err := e.ID(id). + Cols("num_issues", "num_closed_issues"). SetExpr("num_issues", builder.Select("count(*)").From("issue").Where( builder.Eq{"milestone_id": id}, )). @@ -194,8 +197,8 @@ func UpdateMilestoneCounters(ctx context.Context, id int64) error { if err != nil { return err } - _, err = e.Exec("UPDATE `milestone` SET completeness=100*num_closed_issues/(CASE WHEN num_issues > 0 THEN num_issues ELSE 1 END) WHERE id=?", - id, + _, err = e.Exec("UPDATE `milestone` SET completeness=(CASE WHEN is_closed = ? AND num_issues = 0 THEN 100 ELSE 100*num_closed_issues/(CASE WHEN num_issues > 0 THEN num_issues ELSE 1 END) END) WHERE id=?", + true, id, ) return err } @@ -239,6 +242,11 @@ func changeMilestoneStatus(ctx context.Context, m *Milestone, isClosed bool) err if count < 1 { return nil } + + if err := UpdateMilestoneCounters(ctx, m.ID); err != nil { + return err + } + return updateRepoMilestoneNum(ctx, m.RepoID) } diff --git a/models/issues/milestone_list.go b/models/issues/milestone_list.go index 955ab2356df..021b8beb9e0 100644 --- a/models/issues/milestone_list.go +++ b/models/issues/milestone_list.go @@ -24,6 +24,18 @@ func (milestones MilestoneList) getMilestoneIDs() []int64 { return ids } +// SplitByOpenClosed splits the milestone list into open and closed milestones +func (milestones MilestoneList) SplitByOpenClosed() (open, closed MilestoneList) { + for _, m := range milestones { + if m.IsClosed { + closed = append(closed, m) + } else { + open = append(open, m) + } + } + return open, closed +} + // FindMilestoneOptions contain options to get milestones type FindMilestoneOptions struct { db.ListOptions diff --git a/models/issues/pull.go b/models/issues/pull.go index 7a37b627e1b..c07044f3011 100644 --- a/models/issues/pull.go +++ b/models/issues/pull.go @@ -9,7 +9,6 @@ import ( "errors" "fmt" "io" - "regexp" "strings" "code.gitea.io/gitea/models/db" @@ -24,6 +23,7 @@ import ( "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" + "github.com/dlclark/regexp2" "xorm.io/builder" ) @@ -417,10 +417,6 @@ func (pr *PullRequest) GetGitHeadRefName() string { return fmt.Sprintf("%s%d/head", git.PullPrefix, pr.Index) } -func (pr *PullRequest) GetGitHeadBranchRefName() string { - return fmt.Sprintf("%s%s", git.BranchPrefix, pr.HeadBranch) -} - // GetReviewCommentsCount returns the number of review comments made on the diff of a PR review (not including comments on commits or issues in a PR) func (pr *PullRequest) GetReviewCommentsCount(ctx context.Context) int { opts := FindCommentsOptions{ @@ -471,13 +467,13 @@ func NewPullRequest(ctx context.Context, repo *repo_model.Repository, issue *Iss issue.Index = idx issue.Title = util.EllipsisDisplayString(issue.Title, 255) + issue.IsPull = true if err = NewIssueWithIndex(ctx, issue.Poster, NewIssueOptions{ Repo: repo, Issue: issue, LabelIDs: labelIDs, Attachments: uuids, - IsPull: true, }); err != nil { if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) || IsErrNewIssueInsert(err) { return err @@ -646,9 +642,8 @@ func (pr *PullRequest) UpdateCols(ctx context.Context, cols ...string) error { } // UpdateColsIfNotMerged updates specific fields of a pull request if it has not been merged -func (pr *PullRequest) UpdateColsIfNotMerged(ctx context.Context, cols ...string) error { - _, err := db.GetEngine(ctx).Where("id = ? AND has_merged = ?", pr.ID, false).Cols(cols...).Update(pr) - return err +func (pr *PullRequest) UpdateColsIfNotMerged(ctx context.Context, cols ...string) (int64, error) { + return db.GetEngine(ctx).Where("id = ? AND has_merged = ?", pr.ID, false).Cols(cols...).Update(pr) } // IsWorkInProgress determine if the Pull Request is a Work In Progress by its title @@ -663,17 +658,24 @@ func (pr *PullRequest) IsWorkInProgress(ctx context.Context) bool { // HasWorkInProgressPrefix determines if the given PR title has a Work In Progress prefix func HasWorkInProgressPrefix(title string) bool { - for _, prefix := range setting.Repository.PullRequest.WorkInProgressPrefixes { - if strings.HasPrefix(strings.ToUpper(title), strings.ToUpper(prefix)) { - return true - } - } - return false + _, ok := CutWorkInProgressPrefix(title) + return ok } -// IsFilesConflicted determines if the Pull Request has changes conflicting with the target branch. +func CutWorkInProgressPrefix(title string) (origTitle string, ok bool) { + for _, prefix := range setting.Repository.PullRequest.WorkInProgressPrefixes { + prefixLen := len(prefix) + if prefixLen <= len(title) && util.AsciiEqualFold(title[:prefixLen], prefix) { + return title[len(prefix):], true + } + } + return title, false +} + +// IsFilesConflicted determines if the Pull Request has changes conflicting with the target branch. +// Sometimes a conflict may not list any files func (pr *PullRequest) IsFilesConflicted() bool { - return len(pr.ConflictedFiles) > 0 + return pr.Status == PullRequestStatusConflict } // GetWorkInProgressPrefix returns the prefix used to mark the pull request as a work in progress. @@ -859,7 +861,7 @@ func GetCodeOwnersFromContent(ctx context.Context, data string) ([]*CodeOwnerRul } type CodeOwnerRule struct { - Rule *regexp.Regexp + Rule *regexp2.Regexp // it supports negative lookahead, does better for end users Negative bool Users []*user_model.User Teams []*org_model.Team @@ -875,7 +877,8 @@ func ParseCodeOwnersLine(ctx context.Context, tokens []string) (*CodeOwnerRule, warnings := make([]string, 0) - rule.Rule, err = regexp.Compile(fmt.Sprintf("^%s$", strings.TrimPrefix(tokens[0], "!"))) + expr := fmt.Sprintf("^%s$", strings.TrimPrefix(tokens[0], "!")) + rule.Rule, err = regexp2.Compile(expr, regexp2.None) if err != nil { warnings = append(warnings, fmt.Sprintf("incorrect codeowner regexp: %s", err)) return nil, warnings diff --git a/models/issues/pull_list.go b/models/issues/pull_list.go index 84f9f6166d1..19d727ecbdb 100644 --- a/models/issues/pull_list.go +++ b/models/issues/pull_list.go @@ -14,6 +14,7 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" "xorm.io/builder" @@ -324,12 +325,26 @@ func (prs PullRequestList) LoadReviews(ctx context.Context) (ReviewList, error) // HasMergedPullRequestInRepo returns whether the user(poster) has merged pull-request in the repo func HasMergedPullRequestInRepo(ctx context.Context, repoID, posterID int64) (bool, error) { - return db.GetEngine(ctx). + return HasMergedPullRequestInRepoBefore(ctx, repoID, posterID, 0, 0) +} + +// HasMergedPullRequestInRepoBefore returns whether the user has a merged PR before a timestamp (0 = no limit) +func HasMergedPullRequestInRepoBefore(ctx context.Context, repoID, posterID int64, beforeUnix timeutil.TimeStamp, excludePullID int64) (bool, error) { + sess := db.GetEngine(ctx). Join("INNER", "pull_request", "pull_request.issue_id = issue.id"). Where("repo_id=?", repoID). And("poster_id=?", posterID). And("is_pull=?", true). - And("pull_request.has_merged=?", true). + And("pull_request.has_merged=?", true) + + if beforeUnix > 0 { + sess.And("pull_request.merged_unix < ?", beforeUnix) + } + if excludePullID > 0 { + sess.And("pull_request.id != ?", excludePullID) + } + + return sess. Select("issue.id"). Limit(1). Get(new(Issue)) diff --git a/models/issues/pull_test.go b/models/issues/pull_test.go index 7089af253b7..25b27cbe9c9 100644 --- a/models/issues/pull_test.go +++ b/models/issues/pull_test.go @@ -130,7 +130,7 @@ func TestLoadRequestedReviewers(t *testing.T) { user1, err := user_model.GetUserByID(t.Context(), 1) assert.NoError(t, err) - comment, err := issues_model.AddReviewRequest(t.Context(), issue, user1, &user_model.User{}) + comment, err := issues_model.AddReviewRequest(t.Context(), issue, user1, &user_model.User{}, false) assert.NoError(t, err) assert.NotNil(t, comment) diff --git a/models/issues/review.go b/models/issues/review.go index b758fa5ffac..22e2e186d91 100644 --- a/models/issues/review.go +++ b/models/issues/review.go @@ -215,25 +215,25 @@ func (r *Review) LoadAttributes(ctx context.Context) (err error) { return err } -// HTMLTypeColorName returns the color used in the ui indicating the review -func (r *Review) HTMLTypeColorName() string { +// HTMLTypeColorClass returns the CSS class used in the ui indicating the review +func (r *Review) HTMLTypeColorClass() string { switch r.Type { case ReviewTypeApprove: if !r.Official { - return "grey" + return "tw-text-text-light" } if r.Stale { - return "yellow" + return "tw-text-yellow" } - return "green" + return "tw-text-green" case ReviewTypeComment: - return "grey" + return "tw-text-text-light" case ReviewTypeReject: - return "red" + return "tw-text-red" case ReviewTypeRequest: - return "yellow" + return "tw-text-yellow" } - return "grey" + return "tw-text-text-light" } // TooltipContent returns the locale string describing the review type @@ -384,7 +384,7 @@ func CreateReview(ctx context.Context, opts CreateReviewOptions) (*Review, error // GetCurrentReview returns the current pending review of reviewer for given issue func GetCurrentReview(ctx context.Context, reviewer *user_model.User, issue *Issue) (*Review, error) { if reviewer == nil { - return nil, nil + return nil, nil //nolint:nilnil // return nil when reviewer is nil } reviews, err := FindReviews(ctx, FindReviewOptions{ Types: []ReviewType{ReviewTypePending}, @@ -643,7 +643,7 @@ func InsertReviews(ctx context.Context, reviews []*Review) error { } // AddReviewRequest add a review request from one reviewer -func AddReviewRequest(ctx context.Context, issue *Issue, reviewer, doer *user_model.User) (*Comment, error) { +func AddReviewRequest(ctx context.Context, issue *Issue, reviewer, doer *user_model.User, isCodeOwners bool) (*Comment, error) { return db.WithTx2(ctx, func(ctx context.Context) (*Comment, error) { sess := db.GetEngine(ctx) @@ -702,6 +702,7 @@ func AddReviewRequest(ctx context.Context, issue *Issue, reviewer, doer *user_mo RemovedAssignee: false, // Use RemovedAssignee as !isRequest AssigneeID: reviewer.ID, // Use AssigneeID as reviewer ID ReviewID: review.ID, + SpecialDoerName: util.Iif(isCodeOwners, SpecialDoerNameCodeOwners, ""), }) if err != nil { return nil, err @@ -767,7 +768,7 @@ func restoreLatestOfficialReview(ctx context.Context, issueID, reviewerID int64) } // AddTeamReviewRequest add a review request from one team -func AddTeamReviewRequest(ctx context.Context, issue *Issue, reviewer *organization.Team, doer *user_model.User) (*Comment, error) { +func AddTeamReviewRequest(ctx context.Context, issue *Issue, reviewer *organization.Team, doer *user_model.User, isCodeOwners bool) (*Comment, error) { return db.WithTx2(ctx, func(ctx context.Context) (*Comment, error) { review, err := GetTeamReviewerByIssueIDAndTeamID(ctx, issue.ID, reviewer.ID) if err != nil && !IsErrReviewNotExist(err) { @@ -812,6 +813,7 @@ func AddTeamReviewRequest(ctx context.Context, issue *Issue, reviewer *organizat RemovedAssignee: false, // Use RemovedAssignee as !isRequest AssigneeTeamID: reviewer.ID, // Use AssigneeTeamID as reviewer team ID ReviewID: review.ID, + SpecialDoerName: util.Iif(isCodeOwners, SpecialDoerNameCodeOwners, ""), }) if err != nil { return nil, fmt.Errorf("CreateComment(): %w", err) diff --git a/models/issues/review_list.go b/models/issues/review_list.go index bbb8c489fa1..d0430cd06d3 100644 --- a/models/issues/review_list.go +++ b/models/issues/review_list.go @@ -173,9 +173,9 @@ func GetReviewsByIssueID(ctx context.Context, issueID int64) (latestReviews, mig reviewersMap := make(map[int64][]*Review) // key is reviewer id originalReviewersMap := make(map[int64][]*Review) // key is original author id reviewTeamsMap := make(map[int64][]*Review) // key is reviewer team id - countedReivewTypes := []ReviewType{ReviewTypeApprove, ReviewTypeReject, ReviewTypeRequest} + countedReviewTypes := []ReviewType{ReviewTypeApprove, ReviewTypeReject, ReviewTypeRequest, ReviewTypeComment} for _, review := range reviews { - if review.ReviewerTeamID == 0 && slices.Contains(countedReivewTypes, review.Type) && !review.Dismissed { + if review.ReviewerTeamID == 0 && slices.Contains(countedReviewTypes, review.Type) && !review.Dismissed { if review.OriginalAuthorID != 0 { originalReviewersMap[review.OriginalAuthorID] = append(originalReviewersMap[review.OriginalAuthorID], review) } else { diff --git a/models/issues/review_test.go b/models/issues/review_test.go index 7b8537cc7df..092d88d1749 100644 --- a/models/issues/review_test.go +++ b/models/issues/review_test.go @@ -122,6 +122,7 @@ func TestGetReviewersByIssueID(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3}) + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) @@ -129,6 +130,12 @@ func TestGetReviewersByIssueID(t *testing.T) { expectedReviews := []*issues_model.Review{} expectedReviews = append(expectedReviews, + &issues_model.Review{ + ID: 5, + Reviewer: user1, + Type: issues_model.ReviewTypeComment, + UpdatedUnix: 946684810, + }, &issues_model.Review{ ID: 7, Reviewer: org3, @@ -167,8 +174,9 @@ func TestGetReviewersByIssueID(t *testing.T) { for _, review := range allReviews { assert.NoError(t, review.LoadReviewer(t.Context())) } - if assert.Len(t, allReviews, 5) { + if assert.Len(t, allReviews, 6) { for i, review := range allReviews { + assert.Equal(t, expectedReviews[i].ID, review.ID) assert.Equal(t, expectedReviews[i].Reviewer, review.Reviewer) assert.Equal(t, expectedReviews[i].Type, review.Type) assert.Equal(t, expectedReviews[i].UpdatedUnix, review.UpdatedUnix) @@ -313,14 +321,28 @@ func TestAddReviewRequest(t *testing.T) { pull.HasMerged = false assert.NoError(t, pull.UpdateCols(t.Context(), "has_merged")) issue.IsClosed = true - _, err = issues_model.AddReviewRequest(t.Context(), issue, reviewer, &user_model.User{}) + _, err = issues_model.AddReviewRequest(t.Context(), issue, reviewer, &user_model.User{}, false) assert.Error(t, err) assert.True(t, issues_model.IsErrReviewRequestOnClosedPR(err)) pull.HasMerged = true assert.NoError(t, pull.UpdateCols(t.Context(), "has_merged")) issue.IsClosed = false - _, err = issues_model.AddReviewRequest(t.Context(), issue, reviewer, &user_model.User{}) + _, err = issues_model.AddReviewRequest(t.Context(), issue, reviewer, &user_model.User{}, false) assert.Error(t, err) assert.True(t, issues_model.IsErrReviewRequestOnClosedPR(err)) + + // Test CODEOWNERS review request stores metadata correctly + pull2 := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2}) + assert.NoError(t, pull2.LoadIssue(t.Context())) + issue2 := pull2.Issue + assert.NoError(t, issue2.LoadRepo(t.Context())) + reviewer2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 7}) + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + comment, err := issues_model.AddReviewRequest(t.Context(), issue2, reviewer2, doer, true) + assert.NoError(t, err) + assert.NotNil(t, comment) + assert.NotNil(t, comment.CommentMetaData) + assert.Equal(t, issues_model.SpecialDoerNameCodeOwners, comment.CommentMetaData.SpecialDoerName) } diff --git a/models/issues/stopwatch.go b/models/issues/stopwatch.go index 761b8f91a01..f119951b091 100644 --- a/models/issues/stopwatch.go +++ b/models/issues/stopwatch.go @@ -12,6 +12,8 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" + + "xorm.io/builder" ) // Stopwatch represents a stopwatch for time tracking. @@ -232,3 +234,14 @@ func CancelStopwatch(ctx context.Context, user *user_model.User, issue *Issue) ( }) return ok, err } + +// RemoveStopwatchesByRepoID removes all stopwatches for a user in a specific repository +// this function should be called before removing all the issues of the repository +func RemoveStopwatchesByRepoID(ctx context.Context, userID, repoID int64) error { + _, err := db.GetEngine(ctx). + Where("`stopwatch`.user_id = ?", userID). + And(builder.In("`stopwatch`.issue_id", + builder.Select("id").From("issue").Where(builder.Eq{"repo_id": repoID}))). + Delete(new(Stopwatch)) + return err +} diff --git a/models/issues/tracked_time.go b/models/issues/tracked_time.go index 9c11881e442..0b5c341f1f2 100644 --- a/models/issues/tracked_time.go +++ b/models/issues/tracked_time.go @@ -311,13 +311,13 @@ func deleteTime(ctx context.Context, t *TrackedTime) error { } // GetTrackedTimeByID returns raw TrackedTime without loading attributes by id -func GetTrackedTimeByID(ctx context.Context, id int64) (*TrackedTime, error) { +func GetTrackedTimeByID(ctx context.Context, issueID, trackedTimeID int64) (*TrackedTime, error) { time := new(TrackedTime) - has, err := db.GetEngine(ctx).ID(id).Get(time) + has, err := db.GetEngine(ctx).ID(trackedTimeID).Where("issue_id = ?", issueID).Get(time) if err != nil { return nil, err } else if !has { - return nil, db.ErrNotExist{Resource: "tracked_time", ID: id} + return nil, db.ErrNotExist{Resource: "tracked_time", ID: trackedTimeID} } return time, nil } diff --git a/models/migrations/base/db.go b/models/migrations/base/db.go index 479a46379c2..16048c2073b 100644 --- a/models/migrations/base/db.go +++ b/models/migrations/base/db.go @@ -5,21 +5,14 @@ package base import ( "context" - "database/sql" "errors" "fmt" - "os" - "path" "reflect" "regexp" "strings" - "time" - "code.gitea.io/gitea/models/db" - "code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" "xorm.io/xorm" "xorm.io/xorm/schemas" @@ -409,7 +402,7 @@ func DropTableColumns(sess *xorm.Session, tableName string, columnNames ...strin cols += "DROP COLUMN `" + col + "` CASCADE" } if _, err := sess.Exec(fmt.Sprintf("ALTER TABLE `%s` %s", tableName, cols)); err != nil { - return fmt.Errorf("Drop table `%s` columns %v: %v", tableName, columnNames, err) + return fmt.Errorf("drop table `%s` columns %v: %w", tableName, columnNames, err) } case setting.Database.Type.IsMySQL(): // Drop indexes on columns first @@ -437,7 +430,7 @@ func DropTableColumns(sess *xorm.Session, tableName string, columnNames ...strin cols += "DROP COLUMN `" + col + "`" } if _, err := sess.Exec(fmt.Sprintf("ALTER TABLE `%s` %s", tableName, cols)); err != nil { - return fmt.Errorf("Drop table `%s` columns %v: %v", tableName, columnNames, err) + return fmt.Errorf("drop table `%s` columns %v: %w", tableName, columnNames, err) } case setting.Database.Type.IsMSSQL(): cols := "" @@ -451,27 +444,27 @@ func DropTableColumns(sess *xorm.Session, tableName string, columnNames ...strin tableName, strings.ReplaceAll(cols, "`", "'")) constraints := make([]string, 0) if err := sess.SQL(sql).Find(&constraints); err != nil { - return fmt.Errorf("Find constraints: %v", err) + return fmt.Errorf("find constraints: %w", err) } for _, constraint := range constraints { if _, err := sess.Exec(fmt.Sprintf("ALTER TABLE `%s` DROP CONSTRAINT `%s`", tableName, constraint)); err != nil { - return fmt.Errorf("Drop table `%s` default constraint `%s`: %v", tableName, constraint, err) + return fmt.Errorf("drop table `%s` default constraint `%s`: %w", tableName, constraint, err) } } sql = fmt.Sprintf("SELECT DISTINCT Name FROM sys.indexes INNER JOIN sys.index_columns ON indexes.index_id = index_columns.index_id AND indexes.object_id = index_columns.object_id WHERE indexes.object_id = OBJECT_ID('%[1]s') AND index_columns.column_id IN (SELECT column_id FROM sys.columns WHERE LOWER(name) IN (%[2]s) AND object_id = OBJECT_ID('%[1]s'))", tableName, strings.ReplaceAll(cols, "`", "'")) constraints = make([]string, 0) if err := sess.SQL(sql).Find(&constraints); err != nil { - return fmt.Errorf("Find constraints: %v", err) + return fmt.Errorf("find constraints: %w", err) } for _, constraint := range constraints { if _, err := sess.Exec(fmt.Sprintf("DROP INDEX `%[2]s` ON `%[1]s`", tableName, constraint)); err != nil { - return fmt.Errorf("Drop index `%[2]s` on `%[1]s`: %v", tableName, constraint, err) + return fmt.Errorf("drop index `%[2]s` on `%[1]s`: %[3]w", tableName, constraint, err) } } if _, err := sess.Exec(fmt.Sprintf("ALTER TABLE `%s` DROP COLUMN %s", tableName, cols)); err != nil { - return fmt.Errorf("Drop table `%s` columns %v: %v", tableName, columnNames, err) + return fmt.Errorf("drop table `%s` columns %v: %w", tableName, columnNames, err) } default: log.Fatal("Unrecognized DB") @@ -515,114 +508,3 @@ func ModifyColumn(x *xorm.Engine, tableName string, col *schemas.Column) error { } return nil } - -func removeAllWithRetry(dir string) error { - var err error - for range 20 { - err = os.RemoveAll(dir) - if err == nil { - break - } - time.Sleep(100 * time.Millisecond) - } - return err -} - -func newXORMEngine() (*xorm.Engine, error) { - if err := db.InitEngine(context.Background()); err != nil { - return nil, err - } - x := unittest.GetXORMEngine() - return x, nil -} - -func deleteDB() error { - switch { - case setting.Database.Type.IsSQLite3(): - if err := util.Remove(setting.Database.Path); err != nil { - return err - } - return os.MkdirAll(path.Dir(setting.Database.Path), os.ModePerm) - - case setting.Database.Type.IsMySQL(): - db, err := sql.Open("mysql", fmt.Sprintf("%s:%s@tcp(%s)/", - setting.Database.User, setting.Database.Passwd, setting.Database.Host)) - if err != nil { - return err - } - defer db.Close() - - if _, err = db.Exec("DROP DATABASE IF EXISTS " + setting.Database.Name); err != nil { - return err - } - - if _, err = db.Exec("CREATE DATABASE IF NOT EXISTS " + setting.Database.Name); err != nil { - return err - } - return nil - case setting.Database.Type.IsPostgreSQL(): - db, err := sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@%s/?sslmode=%s", - setting.Database.User, setting.Database.Passwd, setting.Database.Host, setting.Database.SSLMode)) - if err != nil { - return err - } - defer db.Close() - - if _, err = db.Exec("DROP DATABASE IF EXISTS " + setting.Database.Name); err != nil { - return err - } - - if _, err = db.Exec("CREATE DATABASE " + setting.Database.Name); err != nil { - return err - } - db.Close() - - // Check if we need to setup a specific schema - if len(setting.Database.Schema) != 0 { - db, err = sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=%s", - setting.Database.User, setting.Database.Passwd, setting.Database.Host, setting.Database.Name, setting.Database.SSLMode)) - if err != nil { - return err - } - defer db.Close() - - schrows, err := db.Query(fmt.Sprintf("SELECT 1 FROM information_schema.schemata WHERE schema_name = '%s'", setting.Database.Schema)) - if err != nil { - return err - } - defer schrows.Close() - - if !schrows.Next() { - // Create and setup a DB schema - _, err = db.Exec("CREATE SCHEMA " + setting.Database.Schema) - if err != nil { - return err - } - } - - // Make the user's default search path the created schema; this will affect new connections - _, err = db.Exec(fmt.Sprintf(`ALTER USER "%s" SET search_path = %s`, setting.Database.User, setting.Database.Schema)) - if err != nil { - return err - } - return nil - } - case setting.Database.Type.IsMSSQL(): - host, port := setting.ParseMSSQLHostPort(setting.Database.Host) - db, err := sql.Open("mssql", fmt.Sprintf("server=%s; port=%s; database=%s; user id=%s; password=%s;", - host, port, "master", setting.Database.User, setting.Database.Passwd)) - if err != nil { - return err - } - defer db.Close() - - if _, err = db.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS [%s]", setting.Database.Name)); err != nil { - return err - } - if _, err = db.Exec(fmt.Sprintf("CREATE DATABASE [%s]", setting.Database.Name)); err != nil { - return err - } - } - - return nil -} diff --git a/models/migrations/base/db_test.go b/models/migrations/base/db_test.go index 80bf00b22a0..00635ca72e2 100644 --- a/models/migrations/base/db_test.go +++ b/models/migrations/base/db_test.go @@ -11,6 +11,10 @@ import ( "xorm.io/xorm/names" ) +func TestMain(m *testing.M) { + MainTest(m) +} + func Test_DropTableColumns(t *testing.T) { x, deferable := PrepareTestEnv(t, 0) if x == nil || t.Failed() { diff --git a/models/migrations/base/main_test.go b/models/migrations/base/main_test.go deleted file mode 100644 index c1c789150f9..00000000000 --- a/models/migrations/base/main_test.go +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright 2021 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package base - -import ( - "testing" -) - -func TestMain(m *testing.M) { - MainTest(m) -} diff --git a/models/migrations/base/tests.go b/models/migrations/base/tests.go index 3b52a5e7c72..17ea951b5a6 100644 --- a/models/migrations/base/tests.go +++ b/models/migrations/base/tests.go @@ -4,25 +4,127 @@ package base import ( + "database/sql" "fmt" "os" + "path" "path/filepath" - "runtime" "testing" + "code.gitea.io/gitea/models/db" "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" + "code.gitea.io/gitea/modules/util" "github.com/stretchr/testify/require" "xorm.io/xorm" + "xorm.io/xorm/schemas" ) // FIXME: this file shouldn't be in a normal package, it should only be compiled for tests +func newXORMEngine(t *testing.T) (*xorm.Engine, error) { + if err := db.InitEngine(t.Context()); err != nil { + return nil, err + } + x := unittest.GetXORMEngine() + return x, nil +} + +func deleteDB() error { + switch { + case setting.Database.Type.IsSQLite3(): + if err := util.Remove(setting.Database.Path); err != nil { + return err + } + return os.MkdirAll(path.Dir(setting.Database.Path), os.ModePerm) + + case setting.Database.Type.IsMySQL(): + db, err := sql.Open("mysql", fmt.Sprintf("%s:%s@tcp(%s)/", + setting.Database.User, setting.Database.Passwd, setting.Database.Host)) + if err != nil { + return err + } + defer db.Close() + + if _, err = db.Exec("DROP DATABASE IF EXISTS " + setting.Database.Name); err != nil { + return err + } + + if _, err = db.Exec("CREATE DATABASE IF NOT EXISTS " + setting.Database.Name); err != nil { + return err + } + return nil + case setting.Database.Type.IsPostgreSQL(): + db, err := sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@%s/?sslmode=%s", + setting.Database.User, setting.Database.Passwd, setting.Database.Host, setting.Database.SSLMode)) + if err != nil { + return err + } + defer db.Close() + + if _, err = db.Exec("DROP DATABASE IF EXISTS " + setting.Database.Name); err != nil { + return err + } + + if _, err = db.Exec("CREATE DATABASE " + setting.Database.Name); err != nil { + return err + } + db.Close() + + // Check if we need to setup a specific schema + if len(setting.Database.Schema) != 0 { + db, err = sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=%s", + setting.Database.User, setting.Database.Passwd, setting.Database.Host, setting.Database.Name, setting.Database.SSLMode)) + if err != nil { + return err + } + defer db.Close() + + schrows, err := db.Query(fmt.Sprintf("SELECT 1 FROM information_schema.schemata WHERE schema_name = '%s'", setting.Database.Schema)) + if err != nil { + return err + } + defer schrows.Close() + + if !schrows.Next() { + // Create and setup a DB schema + _, err = db.Exec("CREATE SCHEMA " + setting.Database.Schema) + if err != nil { + return err + } + } + + // Make the user's default search path the created schema; this will affect new connections + _, err = db.Exec(fmt.Sprintf(`ALTER USER "%s" SET search_path = %s`, setting.Database.User, setting.Database.Schema)) + if err != nil { + return err + } + return nil + } + case setting.Database.Type.IsMSSQL(): + host, port := setting.ParseMSSQLHostPort(setting.Database.Host) + db, err := sql.Open("mssql", fmt.Sprintf("server=%s; port=%s; database=%s; user id=%s; password=%s;", + host, port, "master", setting.Database.User, setting.Database.Passwd)) + if err != nil { + return err + } + defer db.Close() + + if _, err = db.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS [%s]", setting.Database.Name)); err != nil { + return err + } + if _, err = db.Exec(fmt.Sprintf("CREATE DATABASE [%s]", setting.Database.Name)); err != nil { + return err + } + } + + return nil +} + // PrepareTestEnv prepares the test environment and reset the database. The skip parameter should usually be 0. // Provide models to be sync'd with the database - in particular any models you expect fixtures to be loaded from. // @@ -39,7 +141,7 @@ func PrepareTestEnv(t *testing.T, skip int, syncModels ...any) (*xorm.Engine, fu return nil, deferFn } - x, err := newXORMEngine() + x, err := newXORMEngine(t) require.NoError(t, err) if x != nil { oldDefer := deferFn @@ -88,51 +190,36 @@ func PrepareTestEnv(t *testing.T, skip int, syncModels ...any) (*xorm.Engine, fu return x, deferFn } -func MainTest(m *testing.M) { +func LoadTableSchemasMap(t *testing.T, x *xorm.Engine) map[string]*schemas.Table { + tables, err := x.DBMetas() + require.NoError(t, err) + tableMap := make(map[string]*schemas.Table) + for _, table := range tables { + tableMap[table.Name] = table + } + return tableMap +} + +func mainTest(m *testing.M) int { testlogger.Init() - giteaRoot := test.SetupGiteaRoot() - giteaBinary := "gitea" - if runtime.GOOS == "windows" { - giteaBinary += ".exe" - } - setting.AppPath = filepath.Join(giteaRoot, giteaBinary) - if _, err := os.Stat(setting.AppPath); err != nil { - testlogger.Fatalf("Could not find gitea binary at %s\n", setting.AppPath) - } - - giteaConf := os.Getenv("GITEA_CONF") - if giteaConf == "" { - giteaConf = filepath.Join(filepath.Dir(setting.AppPath), "tests/sqlite.ini") - _, _ = fmt.Fprintf(os.Stderr, "Environment variable $GITEA_CONF not set - defaulting to %s\n", giteaConf) - } - - if !filepath.IsAbs(giteaConf) { - setting.CustomConf = filepath.Join(giteaRoot, giteaConf) - } else { - setting.CustomConf = giteaConf - } - tmpDataPath, cleanup, err := tempdir.OsTempDir("gitea-test").MkdirTempRandom("data") if err != nil { - testlogger.Fatalf("Unable to create temporary data path %v\n", err) + testlogger.Panicf("Unable to create temporary data path %v\n", err) } defer cleanup() - setting.CustomPath = filepath.Join(setting.AppWorkPath, "custom") setting.AppDataPath = tmpDataPath unittest.InitSettingsForTesting() if err = git.InitFull(); err != nil { - testlogger.Fatalf("Unable to InitFull: %v\n", err) + testlogger.Panicf("Unable to InitFull: %v\n", err) } setting.LoadDBSetting() setting.InitLoggersForTest() - - exitStatus := m.Run() - - if err := removeAllWithRetry(setting.RepoRootPath); err != nil { - _, _ = fmt.Fprintf(os.Stderr, "os.RemoveAll: %v\n", err) - } - os.Exit(exitStatus) + return m.Run() +} + +func MainTest(m *testing.M) { + os.Exit(mainTest(m)) } diff --git a/models/migrations/fixtures/Test_FixCommitStatusTargetURLToUseRunAndJobID/action_run.yml b/models/migrations/fixtures/Test_FixCommitStatusTargetURLToUseRunAndJobID/action_run.yml new file mode 100644 index 00000000000..342adb2a049 --- /dev/null +++ b/models/migrations/fixtures/Test_FixCommitStatusTargetURLToUseRunAndJobID/action_run.yml @@ -0,0 +1,9 @@ +# type ActionRun struct { +# ID int64 `xorm:"pk autoincr"` +# RepoID int64 `xorm:"index"` +# Index int64 +# } +- + id: 106 + repo_id: 1 + index: 7 diff --git a/models/migrations/fixtures/Test_FixCommitStatusTargetURLToUseRunAndJobID/action_run_job.yml b/models/migrations/fixtures/Test_FixCommitStatusTargetURLToUseRunAndJobID/action_run_job.yml new file mode 100644 index 00000000000..4f90a4495ca --- /dev/null +++ b/models/migrations/fixtures/Test_FixCommitStatusTargetURLToUseRunAndJobID/action_run_job.yml @@ -0,0 +1,10 @@ +# type ActionRunJob struct { +# ID int64 `xorm:"pk autoincr"` +# RunID int64 `xorm:"index"` +# } +- + id: 530 + run_id: 106 +- + id: 531 + run_id: 106 diff --git a/models/migrations/fixtures/Test_FixCommitStatusTargetURLToUseRunAndJobID/commit_status.yml b/models/migrations/fixtures/Test_FixCommitStatusTargetURLToUseRunAndJobID/commit_status.yml new file mode 100644 index 00000000000..ceff4c99931 --- /dev/null +++ b/models/migrations/fixtures/Test_FixCommitStatusTargetURLToUseRunAndJobID/commit_status.yml @@ -0,0 +1,29 @@ +# type CommitStatus struct { +# ID int64 `xorm:"pk autoincr"` +# RepoID int64 `xorm:"index"` +# TargetURL string +# } +- + id: 10 + repo_id: 1 + target_url: /testuser/repo1/actions/runs/7/jobs/0 +- + id: 11 + repo_id: 1 + target_url: /testuser/repo1/actions/runs/7/jobs/1 +- + id: 12 + repo_id: 1 + target_url: /otheruser/badrepo/actions/runs/7/jobs/0 +- + id: 13 + repo_id: 1 + target_url: /testuser/repo1/actions/runs/10/jobs/0 +- + id: 14 + repo_id: 1 + target_url: /testuser/repo1/actions/runs/7/jobs/3 +- + id: 15 + repo_id: 1 + target_url: https://ci.example.com/build/123 diff --git a/models/migrations/fixtures/Test_FixCommitStatusTargetURLToUseRunAndJobID/commit_status_summary.yml b/models/migrations/fixtures/Test_FixCommitStatusTargetURLToUseRunAndJobID/commit_status_summary.yml new file mode 100644 index 00000000000..580b2a4f047 --- /dev/null +++ b/models/migrations/fixtures/Test_FixCommitStatusTargetURLToUseRunAndJobID/commit_status_summary.yml @@ -0,0 +1,19 @@ +# type CommitStatusSummary struct { +# ID int64 `xorm:"pk autoincr"` +# RepoID int64 `xorm:"index"` +# SHA string `xorm:"VARCHAR(64) NOT NULL"` +# State string `xorm:"VARCHAR(7) NOT NULL"` +# TargetURL string +# } +- + id: 20 + repo_id: 1 + sha: "012345" + state: success + target_url: /testuser/repo1/actions/runs/7/jobs/0 +- + id: 21 + repo_id: 1 + sha: "678901" + state: success + target_url: https://ci.example.com/build/123 diff --git a/models/migrations/fixtures/Test_FixCommitStatusTargetURLToUseRunAndJobID/repository.yml b/models/migrations/fixtures/Test_FixCommitStatusTargetURLToUseRunAndJobID/repository.yml new file mode 100644 index 00000000000..86cfb926e44 --- /dev/null +++ b/models/migrations/fixtures/Test_FixCommitStatusTargetURLToUseRunAndJobID/repository.yml @@ -0,0 +1,9 @@ +# type Repository struct { +# ID int64 `xorm:"pk autoincr"` +# OwnerName string +# Name string +# } +- + id: 1 + owner_name: testuser + name: repo1 diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 1b1558f39d1..c1d448577c6 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -25,6 +25,7 @@ import ( "code.gitea.io/gitea/models/migrations/v1_23" "code.gitea.io/gitea/models/migrations/v1_24" "code.gitea.io/gitea/models/migrations/v1_25" + "code.gitea.io/gitea/models/migrations/v1_26" "code.gitea.io/gitea/models/migrations/v1_6" "code.gitea.io/gitea/models/migrations/v1_7" "code.gitea.io/gitea/models/migrations/v1_8" @@ -379,8 +380,8 @@ func prepareMigrationTasks() []*migration { newMigration(309, "Improve Notification table indices", v1_23.ImproveNotificationTableIndices), newMigration(310, "Add Priority to ProtectedBranch", v1_23.AddPriorityToProtectedBranch), newMigration(311, "Add TimeEstimate to Issue table", v1_23.AddTimeEstimateColumnToIssueTable), - // Gitea 1.23.0-rc0 ends at migration ID number 311 (database version 312) + newMigration(312, "Add DeleteBranchAfterMerge to AutoMerge", v1_24.AddDeleteBranchAfterMergeForAutoMerge), newMigration(313, "Move PinOrder from issue table to a new table issue_pin", v1_24.MovePinOrderToTableIssuePin), newMigration(314, "Update OwnerID as zero for repository level action tables", v1_24.UpdateOwnerIDOfRepoLevelActionsTables), @@ -390,10 +391,17 @@ func prepareMigrationTasks() []*migration { newMigration(318, "Add anonymous_access_mode for repo_unit", v1_24.AddRepoUnitAnonymousAccessMode), newMigration(319, "Add ExclusiveOrder to Label table", v1_24.AddExclusiveOrderColumnToLabelTable), newMigration(320, "Migrate two_factor_policy to login_source table", v1_24.MigrateSkipTwoFactor), + // Gitea 1.24.0 ends at migration ID number 320 (database version 321) - // Gitea 1.24.0 ends at database version 321 newMigration(321, "Use LONGTEXT for some columns and fix review_state.updated_files column", v1_25.UseLongTextInSomeColumnsAndFixBugs), newMigration(322, "Extend comment tree_path length limit", v1_25.ExtendCommentTreePathLength), + // Gitea 1.25.0 ends at migration ID number 322 (database version 323) + + newMigration(323, "Add support for actions concurrency", v1_26.AddActionsConcurrency), + newMigration(324, "Fix closed milestone completeness for milestones with no issues", v1_26.FixClosedMilestoneCompleteness), + newMigration(325, "Fix missed repo_id when migrate attachments", v1_26.FixMissedRepoIDWhenMigrateAttachments), + newMigration(326, "Migrate commit status target URL to use run ID and job ID", v1_26.FixCommitStatusTargetURLToUseRunAndJobID), + newMigration(327, "Add disabled state to action runners", v1_26.AddDisabledToActionRunner), } return preparedMigrations } diff --git a/models/migrations/v1_12/v128.go b/models/migrations/v1_12/v128.go index 34746dcdc42..ff5b12af18f 100644 --- a/models/migrations/v1_12/v128.go +++ b/models/migrations/v1_12/v128.go @@ -84,17 +84,17 @@ func FixMergeBase(ctx context.Context, x *xorm.Engine) error { if !pr.HasMerged { var err error - pr.MergeBase, _, err = gitcmd.NewCommand("merge-base").AddDashesAndList(pr.BaseBranch, gitRefName).RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath}) + pr.MergeBase, _, err = gitcmd.NewCommand("merge-base").AddDashesAndList(pr.BaseBranch, gitRefName).WithDir(repoPath).RunStdString(ctx) if err != nil { var err2 error - pr.MergeBase, _, err2 = gitcmd.NewCommand("rev-parse").AddDynamicArguments(git.BranchPrefix+pr.BaseBranch).RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath}) + pr.MergeBase, _, err2 = gitcmd.NewCommand("rev-parse").AddDynamicArguments(git.BranchPrefix + pr.BaseBranch).WithDir(repoPath).RunStdString(ctx) if err2 != nil { log.Error("Unable to get merge base for PR ID %d, Index %d in %s/%s. Error: %v & %v", pr.ID, pr.Index, baseRepo.OwnerName, baseRepo.Name, err, err2) continue } } } else { - parentsString, _, err := gitcmd.NewCommand("rev-list", "--parents", "-n", "1").AddDynamicArguments(pr.MergedCommitID).RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath}) + parentsString, _, err := gitcmd.NewCommand("rev-list", "--parents", "-n", "1").AddDynamicArguments(pr.MergedCommitID).WithDir(repoPath).RunStdString(ctx) if err != nil { log.Error("Unable to get parents for merged PR ID %d, Index %d in %s/%s. Error: %v", pr.ID, pr.Index, baseRepo.OwnerName, baseRepo.Name, err) continue @@ -108,7 +108,7 @@ func FixMergeBase(ctx context.Context, x *xorm.Engine) error { refs = append(refs, gitRefName) cmd := gitcmd.NewCommand("merge-base").AddDashesAndList(refs...) - pr.MergeBase, _, err = cmd.RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath}) + pr.MergeBase, _, err = cmd.WithDir(repoPath).RunStdString(ctx) if err != nil { log.Error("Unable to get merge base for merged PR ID %d, Index %d in %s/%s. Error: %v", pr.ID, pr.Index, baseRepo.OwnerName, baseRepo.Name, err) continue diff --git a/models/migrations/v1_12/v134.go b/models/migrations/v1_12/v134.go index d31cc3abdb3..98bb8dbda72 100644 --- a/models/migrations/v1_12/v134.go +++ b/models/migrations/v1_12/v134.go @@ -80,7 +80,7 @@ func RefixMergeBase(ctx context.Context, x *xorm.Engine) error { gitRefName := fmt.Sprintf("refs/pull/%d/head", pr.Index) - parentsString, _, err := gitcmd.NewCommand("rev-list", "--parents", "-n", "1").AddDynamicArguments(pr.MergedCommitID).RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath}) + parentsString, _, err := gitcmd.NewCommand("rev-list", "--parents", "-n", "1").AddDynamicArguments(pr.MergedCommitID).WithDir(repoPath).RunStdString(ctx) if err != nil { log.Error("Unable to get parents for merged PR ID %d, Index %d in %s/%s. Error: %v", pr.ID, pr.Index, baseRepo.OwnerName, baseRepo.Name, err) continue @@ -95,7 +95,7 @@ func RefixMergeBase(ctx context.Context, x *xorm.Engine) error { refs = append(refs, gitRefName) cmd := gitcmd.NewCommand("merge-base").AddDashesAndList(refs...) - pr.MergeBase, _, err = cmd.RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath}) + pr.MergeBase, _, err = cmd.WithDir(repoPath).RunStdString(ctx) if err != nil { log.Error("Unable to get merge base for merged PR ID %d, Index %d in %s/%s. Error: %v", pr.ID, pr.Index, baseRepo.OwnerName, baseRepo.Name, err) continue diff --git a/models/migrations/v1_12/v136.go b/models/migrations/v1_12/v136.go index 0f53278b462..20b892b6cc5 100644 --- a/models/migrations/v1_12/v136.go +++ b/models/migrations/v1_12/v136.go @@ -6,11 +6,10 @@ package v1_12 import ( "fmt" "math" - "path/filepath" - "strings" "time" - "code.gitea.io/gitea/modules/git" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" @@ -85,12 +84,9 @@ func AddCommitDivergenceToPulls(x *xorm.Engine) error { log.Error("Missing base repo with id %d for PR ID %d", pr.BaseRepoID, pr.ID) continue } - userPath := filepath.Join(setting.RepoRootPath, strings.ToLower(baseRepo.OwnerName)) - repoPath := filepath.Join(userPath, strings.ToLower(baseRepo.Name)+".git") - + repoStore := repo_model.StorageRepo(repo_model.RelativePath(baseRepo.OwnerName, baseRepo.Name)) gitRefName := fmt.Sprintf("refs/pull/%d/head", pr.Index) - - divergence, err := git.GetDivergingCommits(graceful.GetManager().HammerContext(), repoPath, pr.BaseBranch, gitRefName) + divergence, err := gitrepo.GetDivergingCommits(graceful.GetManager().HammerContext(), repoStore, pr.BaseBranch, gitRefName) if err != nil { log.Warn("Could not recalculate Divergence for pull: %d", pr.ID) pr.CommitsAhead = 0 diff --git a/models/migrations/v1_18/v229.go b/models/migrations/v1_18/v229.go index bc15e013908..1f69724365a 100644 --- a/models/migrations/v1_18/v229.go +++ b/models/migrations/v1_18/v229.go @@ -21,6 +21,7 @@ func UpdateOpenMilestoneCounts(x *xorm.Engine) error { for _, id := range openMilestoneIDs { _, err := x.ID(id). + Cols("num_issues", "num_closed_issues"). SetExpr("num_issues", builder.Select("count(*)").From("issue").Where( builder.Eq{"milestone_id": id}, )). diff --git a/models/migrations/v1_21/v276.go b/models/migrations/v1_21/v276.go index 3ab7e22cd05..be24b31902e 100644 --- a/models/migrations/v1_21/v276.go +++ b/models/migrations/v1_21/v276.go @@ -5,14 +5,10 @@ package v1_21 import ( "context" - "fmt" - "path/filepath" - "strings" - "code.gitea.io/gitea/modules/git" - giturl "code.gitea.io/gitea/modules/git/url" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" "xorm.io/xorm" ) @@ -163,16 +159,13 @@ func migratePushMirrors(x *xorm.Engine) error { } func getRemoteAddress(ownerName, repoName, remoteName string) (string, error) { - repoPath := filepath.Join(setting.RepoRootPath, strings.ToLower(ownerName), strings.ToLower(repoName)+".git") - if exist, _ := util.IsExist(repoPath); !exist { + ctx := context.Background() + relativePath := repo_model.RelativePath(ownerName, repoName) + if exist, _ := gitrepo.IsRepositoryExist(ctx, repo_model.StorageRepo(relativePath)); !exist { return "", nil } - remoteURL, err := git.GetRemoteAddress(context.Background(), repoPath, remoteName) - if err != nil { - return "", fmt.Errorf("get remote %s's address of %s/%s failed: %v", remoteName, ownerName, repoName, err) - } - u, err := giturl.ParseGitURL(remoteURL) + u, err := gitrepo.GitRemoteGetURL(ctx, repo_model.StorageRepo(relativePath), remoteName) if err != nil { return "", err } diff --git a/models/migrations/v1_25/v321_test.go b/models/migrations/v1_25/v321_test.go index 4897783fd3b..3ef2c68aa33 100644 --- a/models/migrations/v1_25/v321_test.go +++ b/models/migrations/v1_25/v321_test.go @@ -11,6 +11,7 @@ import ( "code.gitea.io/gitea/modules/timeutil" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func Test_UseLongTextInSomeColumnsAndFixBugs(t *testing.T) { @@ -38,33 +39,26 @@ func Test_UseLongTextInSomeColumnsAndFixBugs(t *testing.T) { type Notice struct { ID int64 `xorm:"pk autoincr"` Type int - Description string `xorm:"LONGTEXT"` + Description string `xorm:"TEXT"` CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` } // Prepare and load the testing database - x, deferable := base.PrepareTestEnv(t, 0, new(ReviewState), new(PackageProperty), new(Notice)) - defer deferable() + x, deferrable := base.PrepareTestEnv(t, 0, new(ReviewState), new(PackageProperty), new(Notice)) + defer deferrable() - assert.NoError(t, UseLongTextInSomeColumnsAndFixBugs(x)) + require.NoError(t, UseLongTextInSomeColumnsAndFixBugs(x)) - tables, err := x.DBMetas() - assert.NoError(t, err) + tables := base.LoadTableSchemasMap(t, x) + table := tables["review_state"] + column := table.GetColumn("updated_files") + assert.Equal(t, "LONGTEXT", column.SQLType.Name) - for _, table := range tables { - switch table.Name { - case "review_state": - column := table.GetColumn("updated_files") - assert.NotNil(t, column) - assert.Equal(t, "LONGTEXT", column.SQLType.Name) - case "package_property": - column := table.GetColumn("value") - assert.NotNil(t, column) - assert.Equal(t, "LONGTEXT", column.SQLType.Name) - case "notice": - column := table.GetColumn("description") - assert.NotNil(t, column) - assert.Equal(t, "LONGTEXT", column.SQLType.Name) - } - } + table = tables["package_property"] + column = table.GetColumn("value") + assert.Equal(t, "LONGTEXT", column.SQLType.Name) + + table = tables["notice"] + column = table.GetColumn("description") + assert.Equal(t, "LONGTEXT", column.SQLType.Name) } diff --git a/models/migrations/v1_25/v322_test.go b/models/migrations/v1_25/v322_test.go new file mode 100644 index 00000000000..78d890704c5 --- /dev/null +++ b/models/migrations/v1_25/v322_test.go @@ -0,0 +1,34 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_25 + +import ( + "testing" + + "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/modules/setting" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_ExtendCommentTreePathLength(t *testing.T) { + if setting.Database.Type.IsSQLite3() { + t.Skip("For SQLITE, varchar or char will always be represented as TEXT") + } + + type Comment struct { + ID int64 `xorm:"pk autoincr"` + TreePath string `xorm:"VARCHAR(255)"` + } + + x, deferrable := base.PrepareTestEnv(t, 0, new(Comment)) + defer deferrable() + + require.NoError(t, ExtendCommentTreePathLength(x)) + table := base.LoadTableSchemasMap(t, x)["comment"] + column := table.GetColumn("tree_path") + assert.Contains(t, []string{"NVARCHAR", "VARCHAR"}, column.SQLType.Name) + assert.EqualValues(t, 4000, column.Length) +} diff --git a/models/migrations/v1_26/main_test.go b/models/migrations/v1_26/main_test.go new file mode 100644 index 00000000000..5aa12d553c9 --- /dev/null +++ b/models/migrations/v1_26/main_test.go @@ -0,0 +1,14 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "testing" + + "code.gitea.io/gitea/models/migrations/base" +) + +func TestMain(m *testing.M) { + base.MainTest(m) +} diff --git a/models/migrations/v1_26/v323.go b/models/migrations/v1_26/v323.go new file mode 100644 index 00000000000..b116f73bf0e --- /dev/null +++ b/models/migrations/v1_26/v323.go @@ -0,0 +1,43 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "xorm.io/xorm" +) + +func AddActionsConcurrency(x *xorm.Engine) error { + type ActionRun struct { + RepoID int64 `xorm:"index(repo_concurrency)"` + RawConcurrency string + ConcurrencyGroup string `xorm:"index(repo_concurrency) NOT NULL DEFAULT ''"` + ConcurrencyCancel bool `xorm:"NOT NULL DEFAULT FALSE"` + } + + if _, err := x.SyncWithOptions(xorm.SyncOptions{ + IgnoreDropIndices: true, + }, new(ActionRun)); err != nil { + return err + } + + if err := x.Sync(new(ActionRun)); err != nil { + return err + } + + type ActionRunJob struct { + RepoID int64 `xorm:"index(repo_concurrency)"` + RawConcurrency string + IsConcurrencyEvaluated bool + ConcurrencyGroup string `xorm:"index(repo_concurrency) NOT NULL DEFAULT ''"` + ConcurrencyCancel bool `xorm:"NOT NULL DEFAULT FALSE"` + } + + if _, err := x.SyncWithOptions(xorm.SyncOptions{ + IgnoreDropIndices: true, + }, new(ActionRunJob)); err != nil { + return err + } + + return nil +} diff --git a/models/migrations/v1_26/v324.go b/models/migrations/v1_26/v324.go new file mode 100644 index 00000000000..5d96bfa3cae --- /dev/null +++ b/models/migrations/v1_26/v324.go @@ -0,0 +1,24 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "fmt" + + "xorm.io/xorm" +) + +func FixClosedMilestoneCompleteness(x *xorm.Engine) error { + // Update all milestones to recalculate completeness with the new logic: + // - Closed milestones with 0 issues should show 100% + // - All other milestones should calculate based on closed/total ratio + _, err := x.Exec("UPDATE `milestone` SET completeness=(CASE WHEN is_closed = ? AND num_issues = 0 THEN 100 ELSE 100*num_closed_issues/(CASE WHEN num_issues > 0 THEN num_issues ELSE 1 END) END)", + true, + ) + if err != nil { + return fmt.Errorf("error updating milestone completeness: %w", err) + } + + return nil +} diff --git a/models/migrations/v1_26/v325.go b/models/migrations/v1_26/v325.go new file mode 100644 index 00000000000..d81540f44e9 --- /dev/null +++ b/models/migrations/v1_26/v325.go @@ -0,0 +1,18 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "xorm.io/xorm" +) + +func FixMissedRepoIDWhenMigrateAttachments(x *xorm.Engine) error { + _, err := x.Exec("UPDATE `attachment` SET `repo_id` = (SELECT `repo_id` FROM `issue` WHERE `issue`.`id` = `attachment`.`issue_id`) WHERE `issue_id` > 0 AND (`repo_id` IS NULL OR `repo_id` = 0);") + if err != nil { + return err + } + + _, err = x.Exec("UPDATE `attachment` SET `repo_id` = (SELECT `repo_id` FROM `release` WHERE `release`.`id` = `attachment`.`release_id`) WHERE `release_id` > 0 AND (`repo_id` IS NULL OR `repo_id` = 0);") + return err +} diff --git a/models/migrations/v1_26/v325_test.go b/models/migrations/v1_26/v325_test.go new file mode 100644 index 00000000000..d4a66fee816 --- /dev/null +++ b/models/migrations/v1_26/v325_test.go @@ -0,0 +1,45 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "testing" + + "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/modules/timeutil" + + "github.com/stretchr/testify/require" +) + +func Test_FixMissedRepoIDWhenMigrateAttachments(t *testing.T) { + type Attachment struct { + ID int64 `xorm:"pk autoincr"` + UUID string `xorm:"uuid UNIQUE"` + RepoID int64 `xorm:"INDEX"` // this should not be zero + IssueID int64 `xorm:"INDEX"` // maybe zero when creating + ReleaseID int64 `xorm:"INDEX"` // maybe zero when creating + UploaderID int64 `xorm:"INDEX DEFAULT 0"` // Notice: will be zero before this column added + CommentID int64 `xorm:"INDEX"` + Name string + DownloadCount int64 `xorm:"DEFAULT 0"` + Size int64 `xorm:"DEFAULT 0"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + } + + type Issue struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"INDEX"` + } + + type Release struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"INDEX"` + } + + // Prepare and load the testing database + x, deferrable := base.PrepareTestEnv(t, 0, new(Attachment), new(Issue), new(Release)) + defer deferrable() + + require.NoError(t, FixMissedRepoIDWhenMigrateAttachments(x)) +} diff --git a/models/migrations/v1_26/v326.go b/models/migrations/v1_26/v326.go new file mode 100644 index 00000000000..1ec0af76a01 --- /dev/null +++ b/models/migrations/v1_26/v326.go @@ -0,0 +1,216 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "fmt" + "net/url" + "strconv" + "strings" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + + "xorm.io/xorm" +) + +const actionsRunPath = "/actions/runs/" + +type migrationRepository struct { + ID int64 + OwnerName string + Name string +} + +type migrationActionRun struct { + ID int64 + RepoID int64 + Index int64 +} + +type migrationActionRunJob struct { + ID int64 + RunID int64 +} + +type migrationCommitStatus struct { + ID int64 + RepoID int64 + TargetURL string +} + +func FixCommitStatusTargetURLToUseRunAndJobID(x *xorm.Engine) error { + runByIndexCache := make(map[int64]map[int64]*migrationActionRun) + jobsByRunIDCache := make(map[int64][]int64) + repoLinkCache := make(map[int64]string) + + if err := migrateCommitStatusTargetURL(x, "commit_status", runByIndexCache, jobsByRunIDCache, repoLinkCache); err != nil { + return err + } + return migrateCommitStatusTargetURL(x, "commit_status_summary", runByIndexCache, jobsByRunIDCache, repoLinkCache) +} + +func migrateCommitStatusTargetURL( + x *xorm.Engine, + table string, + runByIndexCache map[int64]map[int64]*migrationActionRun, + jobsByRunIDCache map[int64][]int64, + repoLinkCache map[int64]string, +) error { + const batchSize = 500 + var lastID int64 + + for { + var rows []migrationCommitStatus + sess := x.Table(table). + Where("target_url LIKE ?", "%"+actionsRunPath+"%"). + And("id > ?", lastID). + Asc("id"). + Limit(batchSize) + if err := sess.Find(&rows); err != nil { + return fmt.Errorf("query %s: %w", table, err) + } + if len(rows) == 0 { + return nil + } + + for _, row := range rows { + lastID = row.ID + if row.TargetURL == "" { + continue + } + + repoLink, err := getRepoLinkCached(x, repoLinkCache, row.RepoID) + if err != nil || repoLink == "" { + if err != nil { + log.Warn("convert %s id=%d getRepoLinkCached: %v", table, row.ID, err) + } else { + log.Warn("convert %s id=%d: repo=%d not found", table, row.ID, row.RepoID) + } + continue + } + + runNum, jobNum, ok := parseTargetURL(row.TargetURL, repoLink) + if !ok { + continue + } + + run, err := getRunByIndexCached(x, runByIndexCache, row.RepoID, runNum) + if err != nil || run == nil { + if err != nil { + log.Warn("convert %s id=%d getRunByIndexCached: %v", table, row.ID, err) + } else { + log.Warn("convert %s id=%d: run not found for repo_id=%d run_index=%d", table, row.ID, row.RepoID, runNum) + } + continue + } + + jobID, ok, err := getJobIDByIndexCached(x, jobsByRunIDCache, run.ID, jobNum) + if err != nil || !ok { + if err != nil { + log.Warn("convert %s id=%d getJobIDByIndexCached: %v", table, row.ID, err) + } else { + log.Warn("convert %s id=%d: job not found for run_id=%d job_index=%d", table, row.ID, run.ID, jobNum) + } + continue + } + + oldURL := row.TargetURL + newURL := fmt.Sprintf("%s%s%d/jobs/%d", repoLink, actionsRunPath, run.ID, jobID) // expect: {repo_link}/actions/runs/{run_id}/jobs/{job_id} + if oldURL == newURL { + continue + } + + if _, err := x.Table(table).ID(row.ID).Cols("target_url").Update(&migrationCommitStatus{TargetURL: newURL}); err != nil { + return fmt.Errorf("update %s id=%d target_url from %s to %s: %w", table, row.ID, oldURL, newURL, err) + } + } + } +} + +func getRepoLinkCached(x *xorm.Engine, cache map[int64]string, repoID int64) (string, error) { + if link, ok := cache[repoID]; ok { + return link, nil + } + repo := &migrationRepository{} + has, err := x.Table("repository").Where("id=?", repoID).Get(repo) + if err != nil { + return "", err + } + if !has { + cache[repoID] = "" + return "", nil + } + link := setting.AppSubURL + "/" + url.PathEscape(repo.OwnerName) + "/" + url.PathEscape(repo.Name) + cache[repoID] = link + return link, nil +} + +func getRunByIndexCached(x *xorm.Engine, cache map[int64]map[int64]*migrationActionRun, repoID, runIndex int64) (*migrationActionRun, error) { + if repoCache, ok := cache[repoID]; ok { + if run, ok := repoCache[runIndex]; ok { + if run == nil { + return nil, fmt.Errorf("run repo_id=%d run_index=%d not found", repoID, runIndex) + } + return run, nil + } + } + + var run migrationActionRun + has, err := x.Table("action_run").Where("repo_id=? AND `index`=?", repoID, runIndex).Get(&run) + if err != nil { + return nil, err + } + if !has { + if cache[repoID] == nil { + cache[repoID] = make(map[int64]*migrationActionRun) + } + cache[repoID][runIndex] = nil + return nil, fmt.Errorf("run repo_id=%d run_index=%d not found", repoID, runIndex) + } + if cache[repoID] == nil { + cache[repoID] = make(map[int64]*migrationActionRun) + } + cache[repoID][runIndex] = &run + return &run, nil +} + +func getJobIDByIndexCached(x *xorm.Engine, cache map[int64][]int64, runID, jobIndex int64) (int64, bool, error) { + jobIDs, ok := cache[runID] + if !ok { + var jobs []migrationActionRunJob + if err := x.Table("action_run_job").Where("run_id=?", runID).Asc("id").Cols("id").Find(&jobs); err != nil { + return 0, false, err + } + jobIDs = make([]int64, 0, len(jobs)) + for _, job := range jobs { + jobIDs = append(jobIDs, job.ID) + } + cache[runID] = jobIDs + } + if jobIndex < 0 || jobIndex >= int64(len(jobIDs)) { + return 0, false, nil + } + return jobIDs[jobIndex], true, nil +} + +func parseTargetURL(targetURL, repoLink string) (runNum, jobNum int64, ok bool) { + prefix := repoLink + actionsRunPath + if !strings.HasPrefix(targetURL, prefix) { + return 0, 0, false + } + rest := targetURL[len(prefix):] + + parts := strings.Split(rest, "/") // expect: {run_num}/jobs/{job_num} + if len(parts) == 3 && parts[1] == "jobs" { + runNum, err1 := strconv.ParseInt(parts[0], 10, 64) + jobNum, err2 := strconv.ParseInt(parts[2], 10, 64) + if err1 != nil || err2 != nil { + return 0, 0, false + } + return runNum, jobNum, true + } + + return 0, 0, false +} diff --git a/models/migrations/v1_26/v326_test.go b/models/migrations/v1_26/v326_test.go new file mode 100644 index 00000000000..ddc26401606 --- /dev/null +++ b/models/migrations/v1_26/v326_test.go @@ -0,0 +1,104 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "testing" + + "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + + _ "code.gitea.io/gitea/models/actions" + _ "code.gitea.io/gitea/models/git" + _ "code.gitea.io/gitea/models/repo" + + "github.com/stretchr/testify/require" + "xorm.io/xorm" +) + +func Test_FixCommitStatusTargetURLToUseRunAndJobID(t *testing.T) { + defer test.MockVariableValue(&setting.AppSubURL, "")() + + type Repository struct { + ID int64 `xorm:"pk autoincr"` + OwnerName string + Name string + } + + type ActionRun struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"index"` + Index int64 + } + + type ActionRunJob struct { + ID int64 `xorm:"pk autoincr"` + RunID int64 `xorm:"index"` + } + + type CommitStatus struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"index"` + TargetURL string + } + + type CommitStatusSummary struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"index"` + SHA string `xorm:"VARCHAR(64) NOT NULL"` + State string `xorm:"VARCHAR(7) NOT NULL"` + TargetURL string + } + + x, deferable := base.PrepareTestEnv(t, 0, + new(Repository), + new(ActionRun), + new(ActionRunJob), + new(CommitStatus), + new(CommitStatusSummary), + ) + defer deferable() + + newURL1 := "/testuser/repo1/actions/runs/106/jobs/530" + newURL2 := "/testuser/repo1/actions/runs/106/jobs/531" + + invalidWrongRepo := "/otheruser/badrepo/actions/runs/7/jobs/0" + invalidNonexistentRun := "/testuser/repo1/actions/runs/10/jobs/0" + invalidNonexistentJob := "/testuser/repo1/actions/runs/7/jobs/3" + externalTargetURL := "https://ci.example.com/build/123" + + require.NoError(t, FixCommitStatusTargetURLToUseRunAndJobID(x)) + + cases := []struct { + table string + id int64 + want string + }{ + {table: "commit_status", id: 10, want: newURL1}, + {table: "commit_status", id: 11, want: newURL2}, + {table: "commit_status", id: 12, want: invalidWrongRepo}, + {table: "commit_status", id: 13, want: invalidNonexistentRun}, + {table: "commit_status", id: 14, want: invalidNonexistentJob}, + {table: "commit_status", id: 15, want: externalTargetURL}, + {table: "commit_status_summary", id: 20, want: newURL1}, + {table: "commit_status_summary", id: 21, want: externalTargetURL}, + } + + for _, tc := range cases { + assertTargetURL(t, x, tc.table, tc.id, tc.want) + } +} + +func assertTargetURL(t *testing.T, x *xorm.Engine, table string, id int64, want string) { + t.Helper() + + var row struct { + TargetURL string + } + has, err := x.Table(table).Where("id=?", id).Cols("target_url").Get(&row) + require.NoError(t, err) + require.Truef(t, has, "row not found: table=%s id=%d", table, id) + require.Equal(t, want, row.TargetURL) +} diff --git a/models/migrations/v1_26/v327.go b/models/migrations/v1_26/v327.go new file mode 100644 index 00000000000..51af567650d --- /dev/null +++ b/models/migrations/v1_26/v327.go @@ -0,0 +1,17 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import "xorm.io/xorm" + +func AddDisabledToActionRunner(x *xorm.Engine) error { + type ActionRunner struct { + IsDisabled bool `xorm:"is_disabled NOT NULL DEFAULT false"` + } + + _, err := x.SyncWithOptions(xorm.SyncOptions{ + IgnoreDropIndices: true, + }, new(ActionRunner)) + return err +} diff --git a/models/migrations/v1_26/v327_test.go b/models/migrations/v1_26/v327_test.go new file mode 100644 index 00000000000..971707be4f9 --- /dev/null +++ b/models/migrations/v1_26/v327_test.go @@ -0,0 +1,33 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "testing" + + "code.gitea.io/gitea/models/migrations/base" + + "github.com/stretchr/testify/require" +) + +func Test_AddDisabledToActionRunner(t *testing.T) { + type ActionRunner struct { + ID int64 `xorm:"pk autoincr"` + Name string + } + + x, deferable := base.PrepareTestEnv(t, 0, new(ActionRunner)) + defer deferable() + + _, err := x.Insert(&ActionRunner{Name: "runner"}) + require.NoError(t, err) + + require.NoError(t, AddDisabledToActionRunner(x)) + + var isDisabled bool + has, err := x.SQL("SELECT is_disabled FROM action_runner WHERE id = ?", 1).Get(&isDisabled) + require.NoError(t, err) + require.True(t, has) + require.False(t, isDisabled) +} diff --git a/models/migrations/v1_9/v82.go b/models/migrations/v1_9/v82.go index f0307bf07aa..8796b0563db 100644 --- a/models/migrations/v1_9/v82.go +++ b/models/migrations/v1_9/v82.go @@ -6,11 +6,10 @@ package v1_9 import ( "context" "fmt" - "path/filepath" - "strings" + repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/git" - "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/gitrepo" "xorm.io/xorm" ) @@ -34,16 +33,6 @@ func FixReleaseSha1OnReleaseTable(ctx context.Context, x *xorm.Engine) error { Name string } - // UserPath returns the path absolute path of user repositories. - UserPath := func(userName string) string { - return filepath.Join(setting.RepoRootPath, strings.ToLower(userName)) - } - - // RepoPath returns repository path by given user and repository name. - RepoPath := func(userName, repoName string) string { - return filepath.Join(UserPath(userName), strings.ToLower(repoName)+".git") - } - // Update release sha1 const batchSize = 100 sess := x.NewSession() @@ -99,7 +88,7 @@ func FixReleaseSha1OnReleaseTable(ctx context.Context, x *xorm.Engine) error { userCache[repo.OwnerID] = user } - gitRepo, err = git.OpenRepository(ctx, RepoPath(user.Name, repo.Name)) + gitRepo, err = gitrepo.OpenRepository(ctx, repo_model.StorageRepo(repo_model.RelativePath(user.Name, repo.Name))) if err != nil { return err } diff --git a/models/organization/org.go b/models/organization/org.go index 9ece044d6c0..10a2c330e56 100644 --- a/models/organization/org.go +++ b/models/organization/org.go @@ -178,7 +178,7 @@ func (org *Organization) HomeLink() string { return org.AsUser().HomeLink() } -// FindOrgMembersOpts represensts find org members conditions +// FindOrgMembersOpts represents find org members conditions type FindOrgMembersOpts struct { db.ListOptions Doer *user_model.User @@ -429,6 +429,10 @@ func HasOrgOrUserVisible(ctx context.Context, orgOrUser, user *user_model.User) return true } + if !setting.Service.RequireSignInViewStrict && orgOrUser.Visibility == structs.VisibleTypePublic { + return true + } + if (orgOrUser.Visibility == structs.VisibleTypePrivate || user.IsRestricted) && !OrgFromUser(orgOrUser).hasMemberWithUserID(ctx, user.ID) { return false } diff --git a/models/organization/org_test.go b/models/organization/org_test.go index e7c4d2f9f74..7a74c5f5fcc 100644 --- a/models/organization/org_test.go +++ b/models/organization/org_test.go @@ -13,7 +13,9 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -382,6 +384,12 @@ func TestHasOrgVisibleTypePublic(t *testing.T) { assert.True(t, test1) // owner of org assert.True(t, test2) // user not a part of org assert.True(t, test3) // logged out user + + restrictedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29, IsRestricted: true}) + require.True(t, restrictedUser.IsRestricted) + assert.True(t, organization.HasOrgOrUserVisible(t.Context(), org.AsUser(), restrictedUser)) + defer test.MockVariableValue(&setting.Service.RequireSignInViewStrict, true)() + assert.False(t, organization.HasOrgOrUserVisible(t.Context(), org.AsUser(), restrictedUser)) } func TestHasOrgVisibleTypeLimited(t *testing.T) { diff --git a/models/organization/org_user.go b/models/organization/org_user.go index 4d7527c15f7..69cd9609446 100644 --- a/models/organization/org_user.go +++ b/models/organization/org_user.go @@ -174,13 +174,13 @@ func GetOrgAssignees(ctx context.Context, orgID int64) (_ []*user_model.User, er func loadOrganizationOwners(ctx context.Context, users user_model.UserList, orgID int64) (map[int64]*TeamUser, error) { if len(users) == 0 { - return nil, nil + return nil, nil //nolint:nilnil // return nil when there are no users } ownerTeam, err := GetOwnerTeam(ctx, orgID) if err != nil { if IsErrTeamNotExist(err) { log.Error("Organization does not have owner team: %d", orgID) - return nil, nil + return nil, nil //nolint:nilnil // return nil when owner team does not exist } return nil, err } diff --git a/models/organization/team_repo.go b/models/organization/team_repo.go index b3e266dbc76..2652b34c6f7 100644 --- a/models/organization/team_repo.go +++ b/models/organization/team_repo.go @@ -53,24 +53,45 @@ func RemoveTeamRepo(ctx context.Context, teamID, repoID int64) error { // GetTeamsWithAccessToAnyRepoUnit returns all teams in an organization that have given access level to the repository special unit. // This function is only used for finding some teams that can be used as branch protection allowlist or reviewers, it isn't really used for access control. // FIXME: TEAM-UNIT-PERMISSION this logic is not complete, search the fixme keyword to see more details -func GetTeamsWithAccessToAnyRepoUnit(ctx context.Context, orgID, repoID int64, mode perm.AccessMode, unitType unit.Type, unitTypesMore ...unit.Type) ([]*Team, error) { - teams := make([]*Team, 0, 5) +func GetTeamsWithAccessToAnyRepoUnit(ctx context.Context, orgID, repoID int64, mode perm.AccessMode, unitType unit.Type, unitTypesMore ...unit.Type) (teams []*Team, err error) { + teamIDs, err := getTeamIDsWithAccessToAnyRepoUnit(ctx, orgID, repoID, mode, unitType, unitTypesMore...) + if err != nil { + return nil, err + } + if len(teamIDs) == 0 { + return teams, nil + } + err = db.GetEngine(ctx).Where(builder.In("id", teamIDs)).OrderBy("team.name").Find(&teams) + return teams, err +} +func getTeamIDsWithAccessToAnyRepoUnit(ctx context.Context, orgID, repoID int64, mode perm.AccessMode, unitType unit.Type, unitTypesMore ...unit.Type) (teamIDs []int64, err error) { sub := builder.Select("team_id").From("team_unit"). Where(builder.Expr("team_unit.team_id = team.id")). And(builder.In("team_unit.type", append([]unit.Type{unitType}, unitTypesMore...))). And(builder.Expr("team_unit.access_mode >= ?", mode)) - err := db.GetEngine(ctx). + err = db.GetEngine(ctx). + Select("team.id"). + Table("team"). Join("INNER", "team_repo", "team_repo.team_id = team.id"). - And("team_repo.org_id = ?", orgID). - And("team_repo.repo_id = ?", repoID). + And("team_repo.org_id = ? AND team_repo.repo_id = ?", orgID, repoID). And(builder.Or( builder.Expr("team.authorize >= ?", mode), builder.In("team.id", sub), )). - OrderBy("name"). - Find(&teams) - - return teams, err + Find(&teamIDs) + return teamIDs, err +} + +func GetTeamUserIDsWithAccessToAnyRepoUnit(ctx context.Context, orgID, repoID int64, mode perm.AccessMode, unitType unit.Type, unitTypesMore ...unit.Type) (userIDs []int64, err error) { + teamIDs, err := getTeamIDsWithAccessToAnyRepoUnit(ctx, orgID, repoID, mode, unitType, unitTypesMore...) + if err != nil { + return nil, err + } + if len(teamIDs) == 0 { + return userIDs, nil + } + err = db.GetEngine(ctx).Table("team_user").Select("uid").Where(builder.In("team_id", teamIDs)).Find(&userIDs) + return userIDs, err } diff --git a/models/packages/package_blob.go b/models/packages/package_blob.go index d9c30b65333..e765bbf0c21 100644 --- a/models/packages/package_blob.go +++ b/models/packages/package_blob.go @@ -43,13 +43,15 @@ func GetOrInsertBlob(ctx context.Context, pb *PackageBlob) (*PackageBlob, bool, existing := &PackageBlob{} - has, err := e.Where(builder.Eq{ + hashCond := builder.Eq{ "size": pb.Size, "hash_md5": pb.HashMD5, "hash_sha1": pb.HashSHA1, "hash_sha256": pb.HashSHA256, "hash_sha512": pb.HashSHA512, - }).Get(existing) + } + + has, err := e.Where(hashCond).Get(existing) if err != nil { return nil, false, err } @@ -57,6 +59,11 @@ func GetOrInsertBlob(ctx context.Context, pb *PackageBlob) (*PackageBlob, bool, return existing, true, nil } if _, err = e.Insert(pb); err != nil { + // Handle race condition: another request may have inserted the same blob + // between our SELECT and INSERT. Retry the SELECT to get the existing blob. + if has, _ = e.Where(hashCond).Get(existing); has { + return existing, true, nil + } return nil, false, err } return pb, false, nil diff --git a/models/packages/package_blob_test.go b/models/packages/package_blob_test.go new file mode 100644 index 00000000000..8b636b4ee00 --- /dev/null +++ b/models/packages/package_blob_test.go @@ -0,0 +1,51 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package packages + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sync/errgroup" +) + +func TestGetOrInsertBlobConcurrent(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + testBlob := PackageBlob{ + Size: 123, + HashMD5: "md5", + HashSHA1: "sha1", + HashSHA256: "sha256", + HashSHA512: "sha512", + } + + const numGoroutines = 3 + var wg errgroup.Group + results := make([]*PackageBlob, numGoroutines) + existed := make([]bool, numGoroutines) + for idx := range numGoroutines { + wg.Go(func() error { + blob := testBlob // Create a copy of the test blob for each goroutine + var err error + results[idx], existed[idx], err = GetOrInsertBlob(t.Context(), &blob) + return err + }) + } + require.NoError(t, wg.Wait()) + + // then: all GetOrInsertBlob succeeds with the same blob ID, and only one indicates it did not exist before + existedCount := 0 + assert.NotNil(t, results[0]) + for i := range numGoroutines { + assert.Equal(t, results[0].ID, results[i].ID) + if existed[i] { + existedCount++ + } + } + assert.Equal(t, numGoroutines-1, existedCount) +} diff --git a/models/perm/access/access.go b/models/perm/access/access.go index 6a0a901f719..6433c4675cf 100644 --- a/models/perm/access/access.go +++ b/models/perm/access/access.go @@ -13,6 +13,8 @@ import ( "code.gitea.io/gitea/models/perm" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" "xorm.io/builder" ) @@ -41,7 +43,12 @@ func accessLevel(ctx context.Context, user *user_model.User, repo *repo_model.Re restricted = user.IsRestricted } - if !restricted && !repo.IsPrivate { + if err := repo.LoadOwner(ctx); err != nil { + return mode, err + } + + repoIsFullyPublic := !setting.Service.RequireSignInViewStrict && repo.Owner.Visibility == structs.VisibleTypePublic && !repo.IsPrivate + if (restricted && repoIsFullyPublic) || (!restricted && !repo.IsPrivate) { mode = perm.AccessModeRead } diff --git a/models/perm/access/access_test.go b/models/perm/access/access_test.go index f01993ab4e4..15d18b368cb 100644 --- a/models/perm/access/access_test.go +++ b/models/perm/access/access_test.go @@ -12,6 +12,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" "github.com/stretchr/testify/assert" ) @@ -51,7 +52,14 @@ func TestAccessLevel(t *testing.T) { assert.NoError(t, err) assert.Equal(t, perm_model.AccessModeNone, level) - // restricted user has no access to a public repo + // restricted user has default access to a public repo if no sign-in is required + setting.Service.RequireSignInViewStrict = false + level, err = access_model.AccessLevel(t.Context(), user29, repo1) + assert.NoError(t, err) + assert.Equal(t, perm_model.AccessModeRead, level) + + // restricted user has no access to a public repo if sign-in is required + setting.Service.RequireSignInViewStrict = true level, err = access_model.AccessLevel(t.Context(), user29, repo1) assert.NoError(t, err) assert.Equal(t, perm_model.AccessModeNone, level) diff --git a/models/perm/access/repo_permission.go b/models/perm/access/repo_permission.go index 678b18442ee..3235d83203c 100644 --- a/models/perm/access/repo_permission.go +++ b/models/perm/access/repo_permission.go @@ -5,15 +5,19 @@ package access import ( "context" + "errors" "fmt" "slices" + "strings" + actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" perm_model "code.gitea.io/gitea/models/perm" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" @@ -166,7 +170,8 @@ func (p *Permission) ReadableUnitTypes() []unit.Type { } func (p *Permission) LogString() string { - format := "") + return fmt.Sprintf(format.String(), args...) } func applyPublicAccessPermission(unitType unit.Type, accessMode perm_model.AccessMode, modeMap *map[unit.Type]perm_model.AccessMode) { @@ -253,6 +258,50 @@ func finalProcessRepoUnitPermission(user *user_model.User, perm *Permission) { } } +// GetActionsUserRepoPermission returns the actions user permissions to the repository +func GetActionsUserRepoPermission(ctx context.Context, repo *repo_model.Repository, actionsUser *user_model.User, taskID int64) (perm Permission, err error) { + if actionsUser.ID != user_model.ActionsUserID { + return perm, errors.New("api GetActionsUserRepoPermission can only be called by the actions user") + } + task, err := actions_model.GetTaskByID(ctx, taskID) + if err != nil { + return perm, err + } + + var accessMode perm_model.AccessMode + if task.RepoID != repo.ID { + taskRepo, exist, err := db.GetByID[repo_model.Repository](ctx, task.RepoID) + if err != nil || !exist { + return perm, err + } + actionsCfg := repo.MustGetUnit(ctx, unit.TypeActions).ActionsConfig() + if !actionsCfg.IsCollaborativeOwner(taskRepo.OwnerID) || !taskRepo.IsPrivate { + // The task repo can access the current repo only if the task repo is private and + // the owner of the task repo is a collaborative owner of the current repo. + // FIXME should owner's visibility also be considered here? + + // check permission like simple user but limit to read-only + perm, err = GetUserRepoPermission(ctx, repo, user_model.NewActionsUser()) + if err != nil { + return perm, err + } + perm.AccessMode = min(perm.AccessMode, perm_model.AccessModeRead) + return perm, nil + } + accessMode = perm_model.AccessModeRead + } else if task.IsForkPullRequest { + accessMode = perm_model.AccessModeRead + } else { + accessMode = perm_model.AccessModeWrite + } + + if err := repo.LoadUnits(ctx); err != nil { + return perm, err + } + perm.SetUnitsWithDefaultAccessMode(repo.Units, accessMode) + return perm, nil +} + // GetUserRepoPermission returns the user permissions to the repository func GetUserRepoPermission(ctx context.Context, repo *repo_model.Repository, user *user_model.User) (perm Permission, err error) { defer func() { @@ -458,54 +507,44 @@ func HasAnyUnitAccess(ctx context.Context, userID int64, repo *repo_model.Reposi return perm.HasAnyUnitAccess(), nil } -// getUsersWithAccessMode returns users that have at least given access mode to the repository. -func getUsersWithAccessMode(ctx context.Context, repo *repo_model.Repository, mode perm_model.AccessMode) (_ []*user_model.User, err error) { - if err = repo.LoadOwner(ctx); err != nil { +func GetUsersWithUnitAccess(ctx context.Context, repo *repo_model.Repository, mode perm_model.AccessMode, unitType unit.Type) (users []*user_model.User, err error) { + userIDs, err := GetUserIDsWithUnitAccess(ctx, repo, mode, unitType) + if err != nil { return nil, err } - - e := db.GetEngine(ctx) - accesses := make([]*Access, 0, 10) - if err = e.Where("repo_id = ? AND mode >= ?", repo.ID, mode).Find(&accesses); err != nil { + if len(userIDs) == 0 { + return users, nil + } + if err = db.GetEngine(ctx).In("id", userIDs.Values()).OrderBy("`name`").Find(&users); err != nil { return nil, err } - - // Leave a seat for owner itself to append later, but if owner is an organization - // and just waste 1 unit is cheaper than re-allocate memory once. - users := make([]*user_model.User, 0, len(accesses)+1) - if len(accesses) > 0 { - userIDs := make([]int64, len(accesses)) - for i := 0; i < len(accesses); i++ { - userIDs[i] = accesses[i].UserID - } - - if err = e.In("id", userIDs).Find(&users); err != nil { - return nil, err - } - } - if !repo.Owner.IsOrganization() { - users = append(users, repo.Owner) - } - return users, nil } -// GetRepoReaders returns all users that have explicit read access or higher to the repository. -func GetRepoReaders(ctx context.Context, repo *repo_model.Repository) (_ []*user_model.User, err error) { - return getUsersWithAccessMode(ctx, repo, perm_model.AccessModeRead) -} - -// GetRepoWriters returns all users that have write access to the repository. -func GetRepoWriters(ctx context.Context, repo *repo_model.Repository) (_ []*user_model.User, err error) { - return getUsersWithAccessMode(ctx, repo, perm_model.AccessModeWrite) -} - -// IsRepoReader returns true if user has explicit read access or higher to the repository. -func IsRepoReader(ctx context.Context, repo *repo_model.Repository, userID int64) (bool, error) { - if repo.OwnerID == userID { - return true, nil +func GetUserIDsWithUnitAccess(ctx context.Context, repo *repo_model.Repository, mode perm_model.AccessMode, unitType unit.Type) (container.Set[int64], error) { + userIDs := container.Set[int64]{} + e := db.GetEngine(ctx) + accesses := make([]*Access, 0, 10) + if err := e.Where("repo_id = ? AND mode >= ?", repo.ID, mode).Find(&accesses); err != nil { + return nil, err } - return db.GetEngine(ctx).Where("repo_id = ? AND user_id = ? AND mode >= ?", repo.ID, userID, perm_model.AccessModeRead).Get(&Access{}) + for _, a := range accesses { + userIDs.Add(a.UserID) + } + + if err := repo.LoadOwner(ctx); err != nil { + return nil, err + } + if !repo.Owner.IsOrganization() { + userIDs.Add(repo.Owner.ID) + } else { + teamUserIDs, err := organization.GetTeamUserIDsWithAccessToAnyRepoUnit(ctx, repo.OwnerID, repo.ID, mode, unitType) + if err != nil { + return nil, err + } + userIDs.AddMultiple(teamUserIDs...) + } + return userIDs, nil } // CheckRepoUnitUser check whether user could visit the unit of this repository diff --git a/models/perm/access/repo_permission_test.go b/models/perm/access/repo_permission_test.go index d81dfba288e..a36be213ece 100644 --- a/models/perm/access/repo_permission_test.go +++ b/models/perm/access/repo_permission_test.go @@ -169,9 +169,9 @@ func TestGetUserRepoPermission(t *testing.T) { user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) team := &organization.Team{OrgID: org.ID, LowerName: "test_team"} require.NoError(t, db.Insert(ctx, team)) + require.NoError(t, db.Insert(ctx, &organization.TeamUser{OrgID: org.ID, TeamID: team.ID, UID: user.ID})) t.Run("DoerInTeamWithNoRepo", func(t *testing.T) { - require.NoError(t, db.Insert(ctx, &organization.TeamUser{OrgID: org.ID, TeamID: team.ID, UID: user.ID})) perm, err := GetUserRepoPermission(ctx, repo32, user) require.NoError(t, err) assert.Equal(t, perm_model.AccessModeRead, perm.AccessMode) @@ -219,6 +219,15 @@ func TestGetUserRepoPermission(t *testing.T) { assert.Equal(t, perm_model.AccessModeNone, perm.AccessMode) assert.Equal(t, perm_model.AccessModeNone, perm.unitsMode[unit.TypeCode]) assert.Equal(t, perm_model.AccessModeRead, perm.unitsMode[unit.TypeIssues]) + + users, err := GetUsersWithUnitAccess(ctx, repo3, perm_model.AccessModeRead, unit.TypeIssues) + require.NoError(t, err) + require.Len(t, users, 1) + assert.Equal(t, user.ID, users[0].ID) + + users, err = GetUsersWithUnitAccess(ctx, repo3, perm_model.AccessModeWrite, unit.TypeIssues) + require.NoError(t, err) + require.Empty(t, users) }) require.NoError(t, db.Insert(ctx, repo_model.Collaboration{RepoID: repo3.ID, UserID: user.ID, Mode: perm_model.AccessModeWrite})) @@ -229,5 +238,10 @@ func TestGetUserRepoPermission(t *testing.T) { assert.Equal(t, perm_model.AccessModeWrite, perm.AccessMode) assert.Equal(t, perm_model.AccessModeWrite, perm.unitsMode[unit.TypeCode]) assert.Equal(t, perm_model.AccessModeWrite, perm.unitsMode[unit.TypeIssues]) + + users, err := GetUsersWithUnitAccess(ctx, repo3, perm_model.AccessModeWrite, unit.TypeIssues) + require.NoError(t, err) + require.Len(t, users, 1) + assert.Equal(t, user.ID, users[0].ID) }) } diff --git a/models/project/column.go b/models/project/column.go index 9b9d874997e..79f6dfe9114 100644 --- a/models/project/column.go +++ b/models/project/column.go @@ -213,6 +213,18 @@ func GetColumn(ctx context.Context, columnID int64) (*Column, error) { return column, nil } +func GetColumnByIDAndProjectID(ctx context.Context, columnID, projectID int64) (*Column, error) { + column := new(Column) + has, err := db.GetEngine(ctx).ID(columnID).And("project_id=?", projectID).Get(column) + if err != nil { + return nil, err + } else if !has { + return nil, ErrProjectColumnNotExist{ColumnID: columnID} + } + + return column, nil +} + // UpdateColumn updates a project column func UpdateColumn(ctx context.Context, column *Column) error { var fieldToUpdate []string diff --git a/models/project/project.go b/models/project/project.go index c003664fa3f..7646c3dd719 100644 --- a/models/project/project.go +++ b/models/project/project.go @@ -302,6 +302,18 @@ func GetProjectByID(ctx context.Context, id int64) (*Project, error) { return p, nil } +func GetProjectByIDAndOwner(ctx context.Context, id, ownerID int64) (*Project, error) { + p := new(Project) + has, err := db.GetEngine(ctx).ID(id).And("owner_id = ?", ownerID).Get(p) + if err != nil { + return nil, err + } else if !has { + return nil, ErrProjectNotExist{ID: id} + } + + return p, nil +} + // GetProjectForRepoByID returns the projects in a repository func GetProjectForRepoByID(ctx context.Context, repoID, id int64) (*Project, error) { p := new(Project) diff --git a/models/pull/review_state.go b/models/pull/review_state.go index 137af00eab2..a0f5548dd44 100644 --- a/models/pull/review_state.go +++ b/models/pull/review_state.go @@ -49,6 +49,19 @@ func init() { db.RegisterModel(new(ReviewState)) } +func (rs *ReviewState) GetViewedFileCount() int { + if len(rs.UpdatedFiles) == 0 { + return 0 + } + var numViewedFiles int + for _, state := range rs.UpdatedFiles { + if state == Viewed { + numViewedFiles++ + } + } + return numViewedFiles +} + // GetReviewState returns the ReviewState with all given values prefilled, whether or not it exists in the database. // If the review didn't exist before in the database, it won't afterwards either. // The returned boolean shows whether the review exists in the database @@ -60,18 +73,18 @@ func GetReviewState(ctx context.Context, userID, pullID int64, commitSHA string) // UpdateReviewState updates the given review inside the database, regardless of whether it existed before or not // The given map of files with their viewed state will be merged with the previous review, if present -func UpdateReviewState(ctx context.Context, userID, pullID int64, commitSHA string, updatedFiles map[string]ViewedState) error { +func UpdateReviewState(ctx context.Context, userID, pullID int64, commitSHA string, updatedFiles map[string]ViewedState) (*ReviewState, error) { log.Trace("Updating review for user %d, repo %d, commit %s with the updated files %v.", userID, pullID, commitSHA, updatedFiles) review, exists, err := GetReviewState(ctx, userID, pullID, commitSHA) if err != nil { - return err + return nil, err } if exists { review.UpdatedFiles = mergeFiles(review.UpdatedFiles, updatedFiles) } else if previousReview, err := getNewestReviewStateApartFrom(ctx, userID, pullID, commitSHA); err != nil { - return err + return nil, err // Overwrite the viewed files of the previous review if present } else if previousReview != nil { @@ -85,11 +98,11 @@ func UpdateReviewState(ctx context.Context, userID, pullID int64, commitSHA stri if !exists { log.Trace("Inserting new review for user %d, repo %d, commit %s with the updated files %v.", userID, pullID, commitSHA, review.UpdatedFiles) _, err := engine.Insert(review) - return err + return nil, err } log.Trace("Updating already existing review with ID %d (user %d, repo %d, commit %s) with the updated files %v.", review.ID, userID, pullID, commitSHA, review.UpdatedFiles) - _, err = engine.ID(review.ID).Update(&ReviewState{UpdatedFiles: review.UpdatedFiles}) - return err + _, err = engine.ID(review.ID).Cols("updated_files").Update(review) + return review, err } // mergeFiles merges the given maps of files with their viewing state into one map. diff --git a/models/renderhelper/repo_file.go b/models/renderhelper/repo_file.go index e0375ed2809..f1df8e89e0e 100644 --- a/models/renderhelper/repo_file.go +++ b/models/renderhelper/repo_file.go @@ -70,6 +70,6 @@ func NewRenderContextRepoFile(ctx context.Context, repo *repo_model.Repository, "repo": helper.opts.DeprecatedRepoName, }) } - rctx = rctx.WithHelper(helper) + rctx = rctx.WithHelper(helper).WithEnableHeadingIDGeneration(true) return rctx } diff --git a/models/renderhelper/repo_wiki.go b/models/renderhelper/repo_wiki.go index b75f1b97017..218b1e4a670 100644 --- a/models/renderhelper/repo_wiki.go +++ b/models/renderhelper/repo_wiki.go @@ -71,7 +71,7 @@ func NewRenderContextRepoWiki(ctx context.Context, repo *repo_model.Repository, "markupAllowShortIssuePattern": "true", }) } - rctx = rctx.WithHelper(helper) + rctx = rctx.WithHelper(helper).WithEnableHeadingIDGeneration(true) helper.ctx = rctx return rctx } diff --git a/modules/git/repo_archive_test.go b/models/repo/archive_test.go similarity index 98% rename from modules/git/repo_archive_test.go rename to models/repo/archive_test.go index ff7e2dfce1b..bb6c1bf9bc2 100644 --- a/modules/git/repo_archive_test.go +++ b/models/repo/archive_test.go @@ -1,7 +1,7 @@ // Copyright 2025 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -package git +package repo import ( "testing" diff --git a/models/repo/archiver.go b/models/repo/archiver.go index d06e94e5ac1..ca981a178c6 100644 --- a/models/repo/archiver.go +++ b/models/repo/archiver.go @@ -11,7 +11,6 @@ import ( "time" "code.gitea.io/gitea/models/db" - "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" @@ -27,11 +26,46 @@ const ( ArchiverReady // it's ready ) +// ArchiveType archive types +type ArchiveType int + +const ( + ArchiveUnknown ArchiveType = iota + ArchiveZip // 1 + ArchiveTarGz // 2 + ArchiveBundle // 3 +) + +// String converts an ArchiveType to string: the extension of the archive file without prefix dot +func (a ArchiveType) String() string { + switch a { + case ArchiveZip: + return "zip" + case ArchiveTarGz: + return "tar.gz" + case ArchiveBundle: + return "bundle" + } + return "unknown" +} + +func SplitArchiveNameType(s string) (string, ArchiveType) { + switch { + case strings.HasSuffix(s, ".zip"): + return strings.TrimSuffix(s, ".zip"), ArchiveZip + case strings.HasSuffix(s, ".tar.gz"): + return strings.TrimSuffix(s, ".tar.gz"), ArchiveTarGz + case strings.HasSuffix(s, ".bundle"): + return strings.TrimSuffix(s, ".bundle"), ArchiveBundle + } + return s, ArchiveUnknown +} + // RepoArchiver represents all archivers type RepoArchiver struct { //revive:disable-line:exported - ID int64 `xorm:"pk autoincr"` - RepoID int64 `xorm:"index unique(s)"` - Type git.ArchiveType `xorm:"unique(s)"` + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"index unique(s)"` + Type ArchiveType `xorm:"unique(s)"` Status ArchiverStatus CommitID string `xorm:"VARCHAR(64) unique(s)"` CreatedUnix timeutil.TimeStamp `xorm:"INDEX NOT NULL created"` @@ -56,15 +90,15 @@ func repoArchiverForRelativePath(relativePath string) (*RepoArchiver, error) { if err != nil { return nil, util.NewInvalidArgumentErrorf("invalid storage path: invalid repo id") } - commitID, archiveType := git.SplitArchiveNameType(parts[2]) - if archiveType == git.ArchiveUnknown { + commitID, archiveType := SplitArchiveNameType(parts[2]) + if archiveType == ArchiveUnknown { return nil, util.NewInvalidArgumentErrorf("invalid storage path: invalid archive type") } return &RepoArchiver{RepoID: repoID, CommitID: commitID, Type: archiveType}, nil } // GetRepoArchiver get an archiver -func GetRepoArchiver(ctx context.Context, repoID int64, tp git.ArchiveType, commitID string) (*RepoArchiver, error) { +func GetRepoArchiver(ctx context.Context, repoID int64, tp ArchiveType, commitID string) (*RepoArchiver, error) { var archiver RepoArchiver has, err := db.GetEngine(ctx).Where("repo_id=?", repoID).And("`type`=?", tp).And("commit_id=?", commitID).Get(&archiver) if err != nil { @@ -73,7 +107,7 @@ func GetRepoArchiver(ctx context.Context, repoID int64, tp git.ArchiveType, comm if has { return &archiver, nil } - return nil, nil + return nil, nil //nolint:nilnil // return nil to indicate that the object does not exist } // ExistsRepoArchiverWithStoragePath checks if there is a RepoArchiver for a given storage path diff --git a/models/repo/attachment.go b/models/repo/attachment.go index 835bee54025..27856f2d2e2 100644 --- a/models/repo/attachment.go +++ b/models/repo/attachment.go @@ -166,6 +166,11 @@ func GetAttachmentByReleaseIDFileName(ctx context.Context, releaseID int64, file return attach, nil } +func GetUnlinkedAttachmentsByUserID(ctx context.Context, userID int64) ([]*Attachment, error) { + attachments := make([]*Attachment, 0, 10) + return attachments, db.GetEngine(ctx).Where("uploader_id = ? AND issue_id = 0 AND release_id = 0 AND comment_id = 0", userID).Find(&attachments) +} + // DeleteAttachment deletes the given attachment and optionally the associated file. func DeleteAttachment(ctx context.Context, a *Attachment, remove bool) error { _, err := DeleteAttachments(ctx, []*Attachment{a}, remove) diff --git a/models/repo/attachment_test.go b/models/repo/attachment_test.go index d41008344d5..07f4c587a7a 100644 --- a/models/repo/attachment_test.go +++ b/models/repo/attachment_test.go @@ -101,3 +101,19 @@ func TestGetAttachmentsByUUIDs(t *testing.T) { assert.Equal(t, int64(1), attachList[0].IssueID) assert.Equal(t, int64(5), attachList[1].IssueID) } + +func TestGetUnlinkedAttachmentsByUserID(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + attachments, err := repo_model.GetUnlinkedAttachmentsByUserID(t.Context(), 8) + assert.NoError(t, err) + assert.Len(t, attachments, 1) + assert.Equal(t, int64(10), attachments[0].ID) + assert.Zero(t, attachments[0].IssueID) + assert.Zero(t, attachments[0].ReleaseID) + assert.Zero(t, attachments[0].CommentID) + + attachments, err = repo_model.GetUnlinkedAttachmentsByUserID(t.Context(), 1) + assert.NoError(t, err) + assert.Empty(t, attachments) +} diff --git a/models/repo/avatar.go b/models/repo/avatar.go index eff64bd2398..bfb08d9982d 100644 --- a/models/repo/avatar.go +++ b/models/repo/avatar.go @@ -41,20 +41,14 @@ func generateRandomAvatar(ctx context.Context, repo *Repository) error { idToString := strconv.FormatInt(repo.ID, 10) seed := idToString - img, err := avatar.RandomImage([]byte(seed)) - if err != nil { - return fmt.Errorf("RandomImage: %w", err) - } + img := avatar.RandomImageDefaultSize([]byte(seed)) repo.Avatar = idToString if err := storage.SaveFrom(storage.RepoAvatars, repo.CustomAvatarRelativePath(), func(w io.Writer) error { - if err := png.Encode(w, img); err != nil { - log.Error("Encode: %v", err) - } - return err + return png.Encode(w, img) }); err != nil { - return fmt.Errorf("Failed to create dir %s: %w", repo.CustomAvatarRelativePath(), err) + return fmt.Errorf("failed to create dir %s: %w", repo.CustomAvatarRelativePath(), err) } log.Info("New random avatar created for repository: %d", repo.ID) diff --git a/models/repo/fork.go b/models/repo/fork.go index 1c75e86458b..80b3e5634e0 100644 --- a/models/repo/fork.go +++ b/models/repo/fork.go @@ -49,7 +49,7 @@ func GetUserFork(ctx context.Context, repoID, userID int64) (*Repository, error) return nil, err } if !has { - return nil, nil + return nil, nil //nolint:nilnil // return nil to indicate that the object does not exist } return &forkedRepo, nil } diff --git a/models/repo/pull_request_default.go b/models/repo/pull_request_default.go new file mode 100644 index 00000000000..89f8eb6a2c3 --- /dev/null +++ b/models/repo/pull_request_default.go @@ -0,0 +1,16 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "context" + + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/util" +) + +func (repo *Repository) GetPullRequestTargetBranch(ctx context.Context) string { + unitPRConfig := repo.MustGetUnit(ctx, unit.TypePullRequests).PullRequestsConfig() + return util.IfZero(unitPRConfig.DefaultTargetBranch, repo.DefaultBranch) +} diff --git a/models/repo/pull_request_default_test.go b/models/repo/pull_request_default_test.go new file mode 100644 index 00000000000..1c4f585ed90 --- /dev/null +++ b/models/repo/pull_request_default_test.go @@ -0,0 +1,32 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "testing" + + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/models/unittest" + + "github.com/stretchr/testify/assert" +) + +func TestDefaultTargetBranchSelection(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + ctx := t.Context() + repo := unittest.AssertExistsAndLoadBean(t, &Repository{ID: 1}) + + assert.Equal(t, repo.DefaultBranch, repo.GetPullRequestTargetBranch(ctx)) + + repo.Units = nil + prUnit, err := repo.GetUnit(ctx, unit.TypePullRequests) + assert.NoError(t, err) + prConfig := prUnit.PullRequestsConfig() + prConfig.DefaultTargetBranch = "branch2" + prUnit.Config = prConfig + assert.NoError(t, UpdateRepoUnit(ctx, prUnit)) + repo.Units = nil + assert.Equal(t, "branch2", repo.GetPullRequestTargetBranch(ctx)) +} diff --git a/models/repo/release.go b/models/repo/release.go index 67aa390e6dc..e2010c8a38d 100644 --- a/models/repo/release.go +++ b/models/repo/release.go @@ -93,15 +93,25 @@ func init() { db.RegisterModel(new(Release)) } -// LoadAttributes load repo and publisher attributes for a release -func (r *Release) LoadAttributes(ctx context.Context) error { - var err error - if r.Repo == nil { - r.Repo, err = GetRepositoryByID(ctx, r.RepoID) - if err != nil { - return err - } +// LegacyAttachmentMissingRepoIDCutoff marks the date when repo_id started to be written during uploads +// (2026-01-16T00:00:00Z). Older rows might have repo_id=0 and should be tolerated once. +const LegacyAttachmentMissingRepoIDCutoff timeutil.TimeStamp = 1768521600 + +func (r *Release) LoadRepo(ctx context.Context) (err error) { + if r.Repo != nil { + return nil } + + r.Repo, err = GetRepositoryByID(ctx, r.RepoID) + return err +} + +// LoadAttributes load repo and publisher attributes for a release +func (r *Release) LoadAttributes(ctx context.Context) (err error) { + if err := r.LoadRepo(ctx); err != nil { + return err + } + if r.Publisher == nil { r.Publisher, err = user_model.GetUserByID(ctx, r.PublisherID) if err != nil { @@ -168,6 +178,11 @@ func UpdateReleaseNumCommits(ctx context.Context, rel *Release) error { // AddReleaseAttachments adds a release attachments func AddReleaseAttachments(ctx context.Context, releaseID int64, attachmentUUIDs []string) (err error) { + rel, err := GetReleaseByID(ctx, releaseID) + if err != nil { + return err + } + // Check attachments attachments, err := GetAttachmentsByUUIDs(ctx, attachmentUUIDs) if err != nil { @@ -175,6 +190,17 @@ func AddReleaseAttachments(ctx context.Context, releaseID int64, attachmentUUIDs } for i := range attachments { + if attachments[i].RepoID == 0 && attachments[i].CreatedUnix < LegacyAttachmentMissingRepoIDCutoff { + attachments[i].RepoID = rel.RepoID + if _, err = db.GetEngine(ctx).ID(attachments[i].ID).Cols("repo_id").Update(attachments[i]); err != nil { + return fmt.Errorf("update attachment repo_id [%d]: %w", attachments[i].ID, err) + } + } + + if attachments[i].RepoID != rel.RepoID { + return util.NewPermissionDeniedErrorf("attachment belongs to different repository") + } + if attachments[i].ReleaseID != 0 { return util.NewPermissionDeniedErrorf("release permission denied") } diff --git a/models/repo/release_test.go b/models/repo/release_test.go index 01f0fb3cff7..2a09ffb36de 100644 --- a/models/repo/release_test.go +++ b/models/repo/release_test.go @@ -6,7 +6,9 @@ package repo import ( "testing" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/util" "github.com/stretchr/testify/assert" ) @@ -37,3 +39,54 @@ func Test_FindTagsByCommitIDs(t *testing.T) { assert.Equal(t, "delete-tag", rels[1].TagName) assert.Equal(t, "v1.0", rels[2].TagName) } + +func TestAddReleaseAttachmentsRejectsDifferentRepo(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + uuid := "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12" // attachment 2 belongs to repo 2 + err := AddReleaseAttachments(t.Context(), 1, []string{uuid}) + assert.Error(t, err) + assert.ErrorIs(t, err, util.ErrPermissionDenied) + + attach, err := GetAttachmentByUUID(t.Context(), uuid) + assert.NoError(t, err) + assert.Zero(t, attach.ReleaseID, "attachment should not be linked to release on failure") +} + +func TestAddReleaseAttachmentsAllowsLegacyMissingRepoID(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + legacyUUID := "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a20" // attachment 10 has repo_id 0 + err := AddReleaseAttachments(t.Context(), 1, []string{legacyUUID}) + assert.NoError(t, err) + + attach, err := GetAttachmentByUUID(t.Context(), legacyUUID) + assert.NoError(t, err) + assert.EqualValues(t, 1, attach.RepoID) + assert.EqualValues(t, 1, attach.ReleaseID) +} + +func TestAddReleaseAttachmentsRejectsRecentZeroRepoID(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + recentUUID := "a0eebc99-9c0b-4ef8-bb6d-6bb9bd3800aa" + attachment := &Attachment{ + UUID: recentUUID, + RepoID: 0, + IssueID: 0, + ReleaseID: 0, + CommentID: 0, + Name: "recent-zero", + CreatedUnix: LegacyAttachmentMissingRepoIDCutoff + 1, + } + assert.NoError(t, db.Insert(t.Context(), attachment)) + + err := AddReleaseAttachments(t.Context(), 1, []string{recentUUID}) + assert.Error(t, err) + assert.ErrorIs(t, err, util.ErrPermissionDenied) + + attach, err := GetAttachmentByUUID(t.Context(), recentUUID) + assert.NoError(t, err) + assert.Zero(t, attach.ReleaseID) + assert.Zero(t, attach.RepoID) +} diff --git a/models/repo/repo.go b/models/repo/repo.go index 8237a429e50..25207cc28b0 100644 --- a/models/repo/repo.go +++ b/models/repo/repo.go @@ -229,10 +229,6 @@ func RelativePath(ownerName, repoName string) string { return strings.ToLower(ownerName) + "/" + strings.ToLower(repoName) + ".git" } -func RelativeWikiPath(ownerName, repoName string) string { - return strings.ToLower(ownerName) + "/" + strings.ToLower(repoName) + ".wiki.git" -} - // RelativePath should be an unix style path like username/reponame.git func (repo *Repository) RelativePath() string { return RelativePath(repo.OwnerName, repo.Name) @@ -245,12 +241,6 @@ func (sr StorageRepo) RelativePath() string { return string(sr) } -// WikiStorageRepo returns the storage repo for the wiki -// The wiki repository should have the same object format as the code repository -func (repo *Repository) WikiStorageRepo() StorageRepo { - return StorageRepo(RelativeWikiPath(repo.OwnerName, repo.Name)) -} - // SanitizedOriginalURL returns a sanitized OriginalURL func (repo *Repository) SanitizedOriginalURL() string { if repo.OriginalURL == "" { @@ -291,7 +281,7 @@ func (repo *Repository) SizeDetailsString() string { var str strings.Builder sizeDetails := repo.SizeDetails() for _, detail := range sizeDetails { - str.WriteString(fmt.Sprintf("%s: %s, ", detail.Name, base.FileSize(detail.Size))) + fmt.Fprintf(&str, "%s: %s, ", detail.Name, base.FileSize(detail.Size)) } return strings.TrimSuffix(str.String(), ", ") } @@ -432,52 +422,37 @@ func (repo *Repository) UnitEnabled(ctx context.Context, tp unit.Type) bool { return false } -// MustGetUnit always returns a RepoUnit object +// MustGetUnit always returns a RepoUnit object even if the unit doesn't exist (not enabled) func (repo *Repository) MustGetUnit(ctx context.Context, tp unit.Type) *RepoUnit { ru, err := repo.GetUnit(ctx, tp) if err == nil { return ru } - + if !errors.Is(err, util.ErrNotExist) { + setting.PanicInDevOrTesting("Failed to get unit %v for repository %d: %v", tp, repo.ID, err) + } + ru = &RepoUnit{RepoID: repo.ID, Type: tp} switch tp { case unit.TypeExternalWiki: - return &RepoUnit{ - Type: tp, - Config: new(ExternalWikiConfig), - } + ru.Config = new(ExternalWikiConfig) case unit.TypeExternalTracker: - return &RepoUnit{ - Type: tp, - Config: new(ExternalTrackerConfig), - } + ru.Config = new(ExternalTrackerConfig) case unit.TypePullRequests: - return &RepoUnit{ - Type: tp, - Config: new(PullRequestsConfig), - } + ru.Config = new(PullRequestsConfig) case unit.TypeIssues: - return &RepoUnit{ - Type: tp, - Config: new(IssuesConfig), - } + ru.Config = new(IssuesConfig) case unit.TypeActions: - return &RepoUnit{ - Type: tp, - Config: new(ActionsConfig), - } + ru.Config = new(ActionsConfig) case unit.TypeProjects: - cfg := new(ProjectsConfig) - cfg.ProjectsMode = ProjectsModeNone - return &RepoUnit{ - Type: tp, - Config: cfg, + ru.Config = new(ProjectsConfig) + default: // other units don't have config + } + if ru.Config != nil { + if err = ru.Config.FromDB(nil); err != nil { + setting.PanicInDevOrTesting("Failed to load default config for unit %v of repository %d: %v", tp, repo.ID, err) } } - - return &RepoUnit{ - Type: tp, - Config: new(UnitConfig), - } + return ru } // GetUnit returns a RepoUnit object @@ -605,7 +580,7 @@ func (repo *Repository) IsGenerated() bool { // RepoPath returns repository path by given user and repository name. func RepoPath(userName, repoName string) string { //revive:disable-line:exported - return filepath.Join(user_model.UserPath(userName), strings.ToLower(repoName)+".git") + return filepath.Join(setting.RepoRootPath, filepath.Clean(strings.ToLower(userName)), filepath.Clean(strings.ToLower(repoName)+".git")) } // RepoPath returns the repository path @@ -623,16 +598,13 @@ func (repo *Repository) ComposeCompareURL(oldCommitID, newCommitID string) strin return fmt.Sprintf("%s/%s/compare/%s...%s", url.PathEscape(repo.OwnerName), url.PathEscape(repo.Name), util.PathEscapeSegments(oldCommitID), util.PathEscapeSegments(newCommitID)) } -func (repo *Repository) ComposeBranchCompareURL(baseRepo *Repository, branchName string) string { - if baseRepo == nil { - baseRepo = repo - } +func (repo *Repository) ComposeBranchCompareURL(baseRepo *Repository, baseBranch, branchName string) string { var cmpBranchEscaped string if repo.ID != baseRepo.ID { cmpBranchEscaped = fmt.Sprintf("%s/%s:", url.PathEscape(repo.OwnerName), url.PathEscape(repo.Name)) } cmpBranchEscaped = fmt.Sprintf("%s%s", cmpBranchEscaped, util.PathEscapeSegments(branchName)) - return fmt.Sprintf("%s/compare/%s...%s", baseRepo.Link(), util.PathEscapeSegments(baseRepo.DefaultBranch), cmpBranchEscaped) + return fmt.Sprintf("%s/compare/%s...%s", baseRepo.Link(), util.PathEscapeSegments(baseBranch), cmpBranchEscaped) } // IsOwnedBy returns true when user owns this repository @@ -879,16 +851,6 @@ func GetRepositoriesMapByIDs(ctx context.Context, ids []int64) (map[int64]*Repos return repos, db.GetEngine(ctx).In("id", ids).Find(&repos) } -// IsRepositoryModelOrDirExist returns true if the repository with given name under user has already existed. -func IsRepositoryModelOrDirExist(ctx context.Context, u *user_model.User, repoName string) (bool, error) { - has, err := IsRepositoryModelExist(ctx, u, repoName) - if err != nil { - return false, err - } - isDir, err := util.IsDir(RepoPath(u.Name, repoName)) - return has || isDir, err -} - func IsRepositoryModelExist(ctx context.Context, u *user_model.User, repoName string) (bool, error) { return db.GetEngine(ctx).Get(&Repository{ OwnerID: u.ID, @@ -901,7 +863,7 @@ func IsRepositoryModelExist(ctx context.Context, u *user_model.User, repoName st // non-generated repositories, and TemplateRepo will be left untouched) func GetTemplateRepo(ctx context.Context, repo *Repository) (*Repository, error) { if !repo.IsGenerated() { - return nil, nil + return nil, nil //nolint:nilnil // return nil for non-generated repositories } return GetRepositoryByID(ctx, repo.TemplateID) diff --git a/models/repo/repo_list.go b/models/repo/repo_list.go index f2cdd2f2846..811f83c9997 100644 --- a/models/repo/repo_list.go +++ b/models/repo/repo_list.go @@ -642,6 +642,17 @@ func SearchRepositoryIDsByCondition(ctx context.Context, cond builder.Cond) ([]i Find(&repoIDs) } +func userAllPublicRepoCond(cond builder.Cond, orgVisibilityLimit []structs.VisibleType) builder.Cond { + return cond.Or(builder.And( + builder.Eq{"`repository`.is_private": false}, + // Aren't in a private organisation or limited organisation if we're not logged in + builder.NotIn("`repository`.owner_id", builder.Select("id").From("`user`").Where( + builder.And( + builder.Eq{"type": user_model.UserTypeOrganization}, + builder.In("visibility", orgVisibilityLimit)), + )))) +} + // AccessibleRepositoryCondition takes a user a returns a condition for checking if a repository is accessible func AccessibleRepositoryCondition(user *user_model.User, unitType unit.Type) builder.Cond { cond := builder.NewCond() @@ -651,15 +662,8 @@ func AccessibleRepositoryCondition(user *user_model.User, unitType unit.Type) bu if user == nil || user.ID <= 0 { orgVisibilityLimit = append(orgVisibilityLimit, structs.VisibleTypeLimited) } - // 1. Be able to see all non-private repositories that either: - cond = cond.Or(builder.And( - builder.Eq{"`repository`.is_private": false}, - // 2. Aren't in an private organisation or limited organisation if we're not logged in - builder.NotIn("`repository`.owner_id", builder.Select("id").From("`user`").Where( - builder.And( - builder.Eq{"type": user_model.UserTypeOrganization}, - builder.In("visibility", orgVisibilityLimit)), - )))) + // 1. Be able to see all non-private repositories + cond = userAllPublicRepoCond(cond, orgVisibilityLimit) } if user != nil { @@ -683,6 +687,9 @@ func AccessibleRepositoryCondition(user *user_model.User, unitType unit.Type) bu if !user.IsRestricted { // 5. Be able to see all public repos in private organizations that we are an org_user of cond = cond.Or(userOrgPublicRepoCond(user.ID)) + } else if !setting.Service.RequireSignInViewStrict { + orgVisibilityLimit := []structs.VisibleType{structs.VisibleTypePrivate, structs.VisibleTypeLimited} + cond = userAllPublicRepoCond(cond, orgVisibilityLimit) } } diff --git a/models/repo/repo_list_test.go b/models/repo/repo_list_test.go index 6cc0d3155ca..943e0c50250 100644 --- a/models/repo/repo_list_test.go +++ b/models/repo/repo_list_test.go @@ -10,9 +10,14 @@ import ( "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func getTestCases() []struct { @@ -182,7 +187,16 @@ func getTestCases() []struct { func TestSearchRepository(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) + t.Run("SearchRepositoryPublic", testSearchRepositoryPublic) + t.Run("SearchRepositoryPublicRestricted", testSearchRepositoryRestricted) + t.Run("SearchRepositoryPrivate", testSearchRepositoryPrivate) + t.Run("SearchRepositoryNonExistingOwner", testSearchRepositoryNonExistingOwner) + t.Run("SearchRepositoryWithInDescription", testSearchRepositoryWithInDescription) + t.Run("SearchRepositoryNotInDescription", testSearchRepositoryNotInDescription) + t.Run("SearchRepositoryCases", testSearchRepositoryCases) +} +func testSearchRepositoryPublic(t *testing.T) { // test search public repository on explore page repos, count, err := repo_model.SearchRepositoryByName(t.Context(), repo_model.SearchRepoOptions{ ListOptions: db.ListOptions{ @@ -211,9 +225,54 @@ func TestSearchRepository(t *testing.T) { assert.NoError(t, err) assert.Equal(t, int64(2), count) assert.Len(t, repos, 2) +} +func testSearchRepositoryRestricted(t *testing.T) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + restrictedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29, IsRestricted: true}) + + performSearch := func(t *testing.T, user *user_model.User) (publicRepoIDs []int64) { + repos, count, err := repo_model.SearchRepositoryByName(t.Context(), repo_model.SearchRepoOptions{ + ListOptions: db.ListOptions{Page: 1, PageSize: 10000}, + Actor: user, + }) + require.NoError(t, err) + assert.Len(t, repos, int(count)) + for _, repo := range repos { + require.NoError(t, repo.LoadOwner(t.Context())) + if repo.Owner.Visibility == structs.VisibleTypePublic && !repo.IsPrivate { + publicRepoIDs = append(publicRepoIDs, repo.ID) + } + } + return publicRepoIDs + } + + normalPublicRepoIDs := performSearch(t, user2) + require.Greater(t, len(normalPublicRepoIDs), 10) // quite a lot + + t.Run("RestrictedUser-NoSignInRequirement", func(t *testing.T) { + // restricted user can also see public repositories if no "required sign-in" + repoIDs := performSearch(t, restrictedUser) + assert.ElementsMatch(t, normalPublicRepoIDs, repoIDs) + }) + + defer test.MockVariableValue(&setting.Service.RequireSignInViewStrict, true)() + + t.Run("NormalUser-RequiredSignIn", func(t *testing.T) { + // normal user can still see all public repos, not affected by "required sign-in" + repoIDs := performSearch(t, user2) + assert.ElementsMatch(t, normalPublicRepoIDs, repoIDs) + }) + t.Run("RestrictedUser-RequiredSignIn", func(t *testing.T) { + // restricted user can see only their own repo + repoIDs := performSearch(t, restrictedUser) + assert.Equal(t, []int64{4}, repoIDs) + }) +} + +func testSearchRepositoryPrivate(t *testing.T) { // test search private repository on explore page - repos, count, err = repo_model.SearchRepositoryByName(t.Context(), repo_model.SearchRepoOptions{ + repos, count, err := repo_model.SearchRepositoryByName(t.Context(), repo_model.SearchRepoOptions{ ListOptions: db.ListOptions{ Page: 1, PageSize: 10, @@ -242,16 +301,18 @@ func TestSearchRepository(t *testing.T) { assert.NoError(t, err) assert.Equal(t, int64(3), count) assert.Len(t, repos, 3) +} - // Test non existing owner - repos, count, err = repo_model.SearchRepositoryByName(t.Context(), repo_model.SearchRepoOptions{OwnerID: unittest.NonexistentID}) +func testSearchRepositoryNonExistingOwner(t *testing.T) { + repos, count, err := repo_model.SearchRepositoryByName(t.Context(), repo_model.SearchRepoOptions{OwnerID: unittest.NonexistentID}) assert.NoError(t, err) assert.Empty(t, repos) assert.Equal(t, int64(0), count) +} - // Test search within description - repos, count, err = repo_model.SearchRepository(t.Context(), repo_model.SearchRepoOptions{ +func testSearchRepositoryWithInDescription(t *testing.T) { + repos, count, err := repo_model.SearchRepository(t.Context(), repo_model.SearchRepoOptions{ ListOptions: db.ListOptions{ Page: 1, PageSize: 10, @@ -266,9 +327,10 @@ func TestSearchRepository(t *testing.T) { assert.Equal(t, "test_repo_14", repos[0].Name) } assert.Equal(t, int64(1), count) +} - // Test NOT search within description - repos, count, err = repo_model.SearchRepository(t.Context(), repo_model.SearchRepoOptions{ +func testSearchRepositoryNotInDescription(t *testing.T) { + repos, count, err := repo_model.SearchRepository(t.Context(), repo_model.SearchRepoOptions{ ListOptions: db.ListOptions{ Page: 1, PageSize: 10, @@ -281,7 +343,9 @@ func TestSearchRepository(t *testing.T) { assert.NoError(t, err) assert.Empty(t, repos) assert.Equal(t, int64(0), count) +} +func testSearchRepositoryCases(t *testing.T) { testCases := getTestCases() for _, testCase := range testCases { diff --git a/models/repo/repo_unit.go b/models/repo/repo_unit.go index a5207bc22a8..491e96770c4 100644 --- a/models/repo/repo_unit.go +++ b/models/repo/repo_unit.go @@ -131,12 +131,28 @@ type PullRequestsConfig struct { DefaultDeleteBranchAfterMerge bool DefaultMergeStyle MergeStyle DefaultAllowMaintainerEdit bool + DefaultTargetBranch string +} + +func DefaultPullRequestsConfig() *PullRequestsConfig { + cfg := &PullRequestsConfig{ + AllowMerge: true, + AllowRebase: true, + AllowRebaseMerge: true, + AllowSquash: true, + AllowFastForwardOnly: true, + AllowRebaseUpdate: true, + DefaultAllowMaintainerEdit: true, + } + cfg.DefaultMergeStyle = MergeStyle(setting.Repository.PullRequest.DefaultMergeStyle) + cfg.DefaultMergeStyle = util.IfZero(cfg.DefaultMergeStyle, MergeStyleMerge) + return cfg } // FromDB fills up a PullRequestsConfig from serialized format. func (cfg *PullRequestsConfig) FromDB(bs []byte) error { - // AllowRebaseUpdate = true as default for existing PullRequestConfig in DB - cfg.AllowRebaseUpdate = true + // set default values for existing PullRequestConfig in DB + *cfg = *DefaultPullRequestsConfig() return json.UnmarshalHandleDoubleEncode(bs, &cfg) } @@ -155,21 +171,15 @@ func (cfg *PullRequestsConfig) IsMergeStyleAllowed(mergeStyle MergeStyle) bool { mergeStyle == MergeStyleManuallyMerged && cfg.AllowManualMerge } -// GetDefaultMergeStyle returns the default merge style for this pull request -func (cfg *PullRequestsConfig) GetDefaultMergeStyle() MergeStyle { - if len(cfg.DefaultMergeStyle) != 0 { - return cfg.DefaultMergeStyle - } - - if setting.Repository.PullRequest.DefaultMergeStyle != "" { - return MergeStyle(setting.Repository.PullRequest.DefaultMergeStyle) - } - - return MergeStyleMerge +func DefaultPullRequestsUnit(repoID int64) RepoUnit { + return RepoUnit{RepoID: repoID, Type: unit.TypePullRequests, Config: DefaultPullRequestsConfig()} } type ActionsConfig struct { DisabledWorkflows []string + // CollaborativeOwnerIDs is a list of owner IDs used to share actions from private repos. + // Only workflows from the private repos whose owners are in CollaborativeOwnerIDs can access the current repo's actions. + CollaborativeOwnerIDs []int64 } func (cfg *ActionsConfig) EnableWorkflow(file string) { @@ -192,6 +202,20 @@ func (cfg *ActionsConfig) DisableWorkflow(file string) { cfg.DisabledWorkflows = append(cfg.DisabledWorkflows, file) } +func (cfg *ActionsConfig) AddCollaborativeOwner(ownerID int64) { + if !slices.Contains(cfg.CollaborativeOwnerIDs, ownerID) { + cfg.CollaborativeOwnerIDs = append(cfg.CollaborativeOwnerIDs, ownerID) + } +} + +func (cfg *ActionsConfig) RemoveCollaborativeOwner(ownerID int64) { + cfg.CollaborativeOwnerIDs = util.SliceRemoveAll(cfg.CollaborativeOwnerIDs, ownerID) +} + +func (cfg *ActionsConfig) IsCollaborativeOwner(ownerID int64) bool { + return slices.Contains(cfg.CollaborativeOwnerIDs, ownerID) +} + // FromDB fills up a ActionsConfig from serialized format. func (cfg *ActionsConfig) FromDB(bs []byte) error { return json.UnmarshalHandleDoubleEncode(bs, &cfg) @@ -223,6 +247,8 @@ type ProjectsConfig struct { // FromDB fills up a ProjectsConfig from serialized format. func (cfg *ProjectsConfig) FromDB(bs []byte) error { + // TODO: remove GetProjectsMode, only use ProjectsMode + cfg.ProjectsMode = ProjectsModeAll return json.UnmarshalHandleDoubleEncode(bs, &cfg) } diff --git a/models/repo/topic.go b/models/repo/topic.go index baeae01efae..6d5209d8210 100644 --- a/models/repo/topic.go +++ b/models/repo/topic.go @@ -159,7 +159,7 @@ func RemoveTopicsFromRepo(ctx context.Context, repoID int64) error { builder.In("id", builder.Select("topic_id").From("repo_topic").Where(builder.Eq{"repo_id": repoID}), ), - ).Cols("repo_count").SetExpr("repo_count", "repo_count-1").Update(&Topic{}) + ).Decr("repo_count").Update(&Topic{}) if err != nil { return err } @@ -257,7 +257,7 @@ func DeleteTopic(ctx context.Context, repoID int64, topicName string) (*Topic, e } if topic == nil { // Repo doesn't have topic, can't be removed - return nil, nil + return nil, nil //nolint:nilnil // return nil to indicate that the topic does not exist } return db.WithTx2(ctx, func(ctx context.Context) (*Topic, error) { diff --git a/models/repo/update.go b/models/repo/update.go index 3228ae11a4e..bf560cf6950 100644 --- a/models/repo/update.go +++ b/models/repo/update.go @@ -9,8 +9,6 @@ import ( "time" "code.gitea.io/gitea/models/db" - user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/util" ) @@ -106,35 +104,6 @@ func (err ErrRepoFilesAlreadyExist) Unwrap() error { return util.ErrAlreadyExist } -// CheckCreateRepository check if doer could create a repository in new owner -func CheckCreateRepository(ctx context.Context, doer, owner *user_model.User, name string, overwriteOrAdopt bool) error { - if !doer.CanCreateRepoIn(owner) { - return ErrReachLimitOfRepo{owner.MaxRepoCreation} - } - - if err := IsUsableRepoName(name); err != nil { - return err - } - - has, err := IsRepositoryModelOrDirExist(ctx, owner, name) - if err != nil { - return fmt.Errorf("IsRepositoryExist: %w", err) - } else if has { - return ErrRepoAlreadyExist{owner.Name, name} - } - - repoPath := RepoPath(owner.Name, name) - isExist, err := util.IsExist(repoPath) - if err != nil { - log.Error("Unable to check if %s exists. Error: %v", repoPath, err) - return err - } - if !overwriteOrAdopt && isExist { - return ErrRepoFilesAlreadyExist{owner.Name, name} - } - return nil -} - // UpdateRepoSize updates the repository size, calculating it using getDirectorySize func UpdateRepoSize(ctx context.Context, repoID, gitSize, lfsSize int64) error { _, err := db.GetEngine(ctx).ID(repoID).Cols("size", "git_size", "lfs_size").NoAutoTime().Update(&Repository{ diff --git a/models/repo/upload.go b/models/repo/upload.go index f7d47498424..b9bda8fdbf5 100644 --- a/models/repo/upload.go +++ b/models/repo/upload.go @@ -127,16 +127,9 @@ func DeleteUploads(ctx context.Context, uploads ...*Upload) (err error) { for _, upload := range uploads { localPath := upload.LocalPath() - isFile, err := util.IsFile(localPath) - if err != nil { - log.Error("Unable to check if %s is a file. Error: %v", localPath, err) - } - if !isFile { - continue - } - if err := util.Remove(localPath); err != nil { - return fmt.Errorf("remove upload: %w", err) + // just continue, don't fail the whole operation if a file is missing (removed by others) + log.Error("unable to remove upload file %s: %v", localPath, err) } } diff --git a/models/repo/user_repo.go b/models/repo/user_repo.go index 232087d8659..e15a64b01e8 100644 --- a/models/repo/user_repo.go +++ b/models/repo/user_repo.go @@ -147,19 +147,21 @@ func GetRepoAssignees(ctx context.Context, repo *Repository) (_ []*user_model.Us } // GetIssuePostersWithSearch returns users with limit of 30 whose username started with prefix that have authored an issue/pull request for the given repository -// If isShowFullName is set to true, also include full name prefix search -func GetIssuePostersWithSearch(ctx context.Context, repo *Repository, isPull bool, search string, isShowFullName bool) ([]*user_model.User, error) { +// It searches with the "user.name" and "user.full_name" fields case-insensitively. +func GetIssuePostersWithSearch(ctx context.Context, repo *Repository, isPull bool, search string) ([]*user_model.User, error) { users := make([]*user_model.User, 0, 30) - var prefixCond builder.Cond = builder.Like{"lower_name", strings.ToLower(search) + "%"} - if isShowFullName { - prefixCond = prefixCond.Or(db.BuildCaseInsensitiveLike("full_name", "%"+search+"%")) - } cond := builder.In("`user`.id", builder.Select("poster_id").From("issue").Where( builder.Eq{"repo_id": repo.ID}. And(builder.Eq{"is_pull": isPull}), - ).GroupBy("poster_id")).And(prefixCond) + ).GroupBy("poster_id")) + + if search != "" { + var prefixCond builder.Cond = builder.Like{"lower_name", strings.ToLower(search) + "%"} + prefixCond = prefixCond.Or(db.BuildCaseInsensitiveLike("full_name", "%"+search+"%")) + cond = cond.And(prefixCond) + } return users, db.GetEngine(ctx). Where(cond). diff --git a/models/repo/user_repo_test.go b/models/repo/user_repo_test.go index a53cf39dc4c..cd8a0f1a1f7 100644 --- a/models/repo/user_repo_test.go +++ b/models/repo/user_repo_test.go @@ -44,12 +44,12 @@ func TestGetIssuePostersWithSearch(t *testing.T) { repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) - users, err := repo_model.GetIssuePostersWithSearch(t.Context(), repo2, false, "USER", false /* full name */) + users, err := repo_model.GetIssuePostersWithSearch(t.Context(), repo2, false, "USER") require.NoError(t, err) require.Len(t, users, 1) assert.Equal(t, "user2", users[0].Name) - users, err = repo_model.GetIssuePostersWithSearch(t.Context(), repo2, false, "TW%O", true /* full name */) + users, err = repo_model.GetIssuePostersWithSearch(t.Context(), repo2, false, "TW%O") require.NoError(t, err) require.Len(t, users, 1) assert.Equal(t, "user2", users[0].Name) diff --git a/models/repo/watch.go b/models/repo/watch.go index a616544cae3..1e63d5c3d2c 100644 --- a/models/repo/watch.go +++ b/models/repo/watch.go @@ -176,3 +176,13 @@ func WatchIfAuto(ctx context.Context, userID, repoID int64, isWrite bool) error } return watchRepoMode(ctx, watch, WatchModeAuto) } + +// ClearRepoWatches clears all watches for a repository and from the user that watched it. +// Used when a repository is set to private. +func ClearRepoWatches(ctx context.Context, repoID int64) error { + if _, err := db.Exec(ctx, "UPDATE `repository` SET num_watches = 0 WHERE id = ?", repoID); err != nil { + return err + } + + return db.DeleteBeans(ctx, Watch{RepoID: repoID}) +} diff --git a/models/repo/watch_test.go b/models/repo/watch_test.go index 19e363f6b08..97576fb787b 100644 --- a/models/repo/watch_test.go +++ b/models/repo/watch_test.go @@ -13,6 +13,7 @@ import ( "code.gitea.io/gitea/modules/setting" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestIsWatching(t *testing.T) { @@ -119,3 +120,21 @@ func TestWatchIfAuto(t *testing.T) { assert.NoError(t, err) assert.Len(t, watchers, prevCount) } + +func TestClearRepoWatches(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + const repoID int64 = 1 + watchers, err := repo_model.GetRepoWatchersIDs(t.Context(), repoID) + require.NoError(t, err) + require.NotEmpty(t, watchers) + + assert.NoError(t, repo_model.ClearRepoWatches(t.Context(), repoID)) + + watchers, err = repo_model.GetRepoWatchersIDs(t.Context(), repoID) + assert.NoError(t, err) + assert.Empty(t, watchers) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID}) + assert.Zero(t, repo.NumWatches) +} diff --git a/models/repo/wiki.go b/models/repo/wiki.go index 9f41445bf84..47c8fa43abc 100644 --- a/models/repo/wiki.go +++ b/models/repo/wiki.go @@ -7,7 +7,6 @@ package repo import ( "context" "fmt" - "path/filepath" "strings" user_model "code.gitea.io/gitea/models/user" @@ -76,12 +75,12 @@ func (repo *Repository) WikiCloneLink(ctx context.Context, doer *user_model.User return repo.cloneLink(ctx, doer, repo.Name+".wiki") } -// WikiPath returns wiki data path by given user and repository name. -func WikiPath(userName, repoName string) string { - return filepath.Join(user_model.UserPath(userName), strings.ToLower(repoName)+".wiki.git") +func RelativeWikiPath(ownerName, repoName string) string { + return strings.ToLower(ownerName) + "/" + strings.ToLower(repoName) + ".wiki.git" } -// WikiPath returns wiki data path for given repository. -func (repo *Repository) WikiPath() string { - return WikiPath(repo.OwnerName, repo.Name) +// WikiStorageRepo returns the storage repo for the wiki +// The wiki repository should have the same object format as the code repository +func (repo *Repository) WikiStorageRepo() StorageRepo { + return StorageRepo(RelativeWikiPath(repo.OwnerName, repo.Name)) } diff --git a/models/repo/wiki_test.go b/models/repo/wiki_test.go index 41e53d93d9e..636c78009b5 100644 --- a/models/repo/wiki_test.go +++ b/models/repo/wiki_test.go @@ -4,12 +4,10 @@ package repo_test import ( - "path/filepath" "testing" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" - "code.gitea.io/gitea/modules/setting" "github.com/stretchr/testify/assert" ) @@ -23,15 +21,10 @@ func TestRepository_WikiCloneLink(t *testing.T) { assert.Equal(t, "https://try.gitea.io/user2/repo1.wiki.git", cloneLink.HTTPS) } -func TestWikiPath(t *testing.T) { +func TestRepository_RelativeWikiPath(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - expected := filepath.Join(setting.RepoRootPath, "user2/repo1.wiki.git") - assert.Equal(t, expected, repo_model.WikiPath("user2", "repo1")) -} -func TestRepository_WikiPath(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) - expected := filepath.Join(setting.RepoRootPath, "user2/repo1.wiki.git") - assert.Equal(t, expected, repo.WikiPath()) + assert.Equal(t, "user2/repo1.wiki.git", repo_model.RelativeWikiPath(repo.OwnerName, repo.Name)) + assert.Equal(t, "user2/repo1.wiki.git", repo.WikiStorageRepo().RelativePath()) } diff --git a/models/secret/secret.go b/models/secret/secret.go index 10a0287dfd9..a82a924c393 100644 --- a/models/secret/secret.go +++ b/models/secret/secret.go @@ -178,8 +178,8 @@ func GetSecretsOfTask(ctx context.Context, task *actions_model.ActionTask) (map[ for _, secret := range append(ownerSecrets, repoSecrets...) { v, err := secret_module.DecryptSecret(setting.SecretKey, secret.Data) if err != nil { - log.Error("decrypt secret %v %q: %v", secret.ID, secret.Name, err) - return nil, err + log.Error("Unable to decrypt Actions secret %v %q, maybe SECRET_KEY is wrong: %v", secret.ID, secret.Name, err) + continue } secrets[secret.Name] = v } diff --git a/models/unittest/fixtures_loader.go b/models/unittest/fixtures_loader.go index d92b0cdb14d..5b79cb5643b 100644 --- a/models/unittest/fixtures_loader.go +++ b/models/unittest/fixtures_loader.go @@ -169,7 +169,7 @@ func (f *fixturesLoaderInternal) Load() error { func FixturesFileFullPaths(dir string, files []string) (map[string]*FixtureItem, error) { if files != nil && len(files) == 0 { - return nil, nil // load nothing + return nil, nil //nolint:nilnil // load nothing } files = slices.Clone(files) if len(files) == 0 { diff --git a/models/unittest/fixtures_test.go b/models/unittest/fixtures_test.go index 8a4c5f1793d..72944ec0dbe 100644 --- a/models/unittest/fixtures_test.go +++ b/models/unittest/fixtures_test.go @@ -4,19 +4,20 @@ package unittest_test import ( + "os" "path/filepath" "testing" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/modules/setting" "github.com/stretchr/testify/require" "xorm.io/xorm" ) var NewFixturesLoaderVendor = func(e *xorm.Engine, opts unittest.FixturesOptions) (unittest.FixturesLoader, error) { - return nil, nil + return nil, nil //nolint:nilnil // no vendor fixtures loader configured } /* @@ -58,9 +59,15 @@ func NewFixturesLoaderVendorGoTestfixtures(e *xorm.Engine, opts unittest.Fixture } */ +func TestMain(m *testing.M) { + setting.SetupGiteaTestEnv() + os.Exit(m.Run()) +} + func prepareTestFixturesLoaders(t testing.TB) unittest.FixturesOptions { _ = user_model.User{} - opts := unittest.FixturesOptions{Dir: filepath.Join(test.SetupGiteaRoot(), "models", "fixtures"), Files: []string{ + giteaRoot := setting.GetGiteaTestSourceRoot() + opts := unittest.FixturesOptions{Dir: filepath.Join(giteaRoot, "models", "fixtures"), Files: []string{ "user.yml", }} require.NoError(t, unittest.CreateTestEngine(opts)) diff --git a/models/unittest/fscopy.go b/models/unittest/fscopy.go index 98b01815bd7..cddb7a3f778 100644 --- a/models/unittest/fscopy.go +++ b/models/unittest/fscopy.go @@ -4,10 +4,12 @@ package unittest import ( + "errors" "os" "path/filepath" "strings" + "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" ) @@ -39,7 +41,20 @@ func SyncFile(srcPath, destPath string) error { // SyncDirs synchronizes files recursively from source to target directory. // It returns error when error occurs in underlying functions. func SyncDirs(srcPath, destPath string) error { - err := os.MkdirAll(destPath, os.ModePerm) + destPath = filepath.Clean(destPath) + destPathAbs, err := filepath.Abs(destPath) + if err != nil { + return err + } + devDataPathAbs, err := filepath.Abs(filepath.Join(setting.GetGiteaTestSourceRoot(), "data")) + if err != nil { + return err + } + if strings.HasPrefix(destPathAbs+string(filepath.Separator), devDataPathAbs+string(filepath.Separator)) { + return errors.New("destination path should not be inside Gitea data directory, otherwise your data for dev mode will be removed") + } + + err = os.MkdirAll(destPath, os.ModePerm) if err != nil { return err } diff --git a/models/unittest/testdb.go b/models/unittest/testdb.go index 4611a079ec3..63c9a3a9994 100644 --- a/models/unittest/testdb.go +++ b/models/unittest/testdb.go @@ -21,7 +21,7 @@ import ( "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/testlogger" "code.gitea.io/gitea/modules/util" "github.com/stretchr/testify/assert" @@ -29,16 +29,10 @@ import ( "xorm.io/xorm/names" ) -var giteaRoot string - -func fatalTestError(fmtStr string, args ...any) { - _, _ = fmt.Fprintf(os.Stderr, fmtStr, args...) - os.Exit(1) -} - // InitSettingsForTesting initializes config provider and load common settings for tests func InitSettingsForTesting() { - setting.IsInTesting = true + setting.SetupGiteaTestEnv() + log.OsExiter = func(code int) { if code != 0 { // non-zero exit code (log.Fatal) shouldn't occur during testing, if it happens, show a full stacktrace for more details @@ -50,8 +44,12 @@ func InitSettingsForTesting() { setting.CustomConf = filepath.Join(setting.CustomPath, "conf/app-unittest-tmp.ini") _ = os.Remove(setting.CustomConf) } - setting.InitCfgProvider(setting.CustomConf) - setting.LoadCommonSettings() + + // init paths and config system for testing + getTestEnv := func(key string) string { + return "" + } + setting.InitWorkPathAndCommonConfig(getTestEnv, setting.ArgWorkPathAndCustomConf{CustomConf: setting.CustomConf}) if err := setting.PrepareAppDataPath(); err != nil { log.Fatal("Can not prepare APP_DATA_PATH: %v", err) @@ -60,7 +58,6 @@ func InitSettingsForTesting() { _ = hash.Register("dummy", hash.NewDummyHasher) setting.PasswordHashAlgo, _ = hash.SetDefaultPasswordHashAlgorithm("dummy") - setting.InitGiteaEnvVarsForTesting() } // TestOptions represents test options @@ -73,17 +70,18 @@ type TestOptions struct { // MainTest a reusable TestMain(..) function for unit tests that need to use a // test database. Creates the test database, and sets necessary settings. func MainTest(m *testing.M, testOptsArg ...*TestOptions) { - testOpts := util.OptionalArg(testOptsArg, &TestOptions{}) - giteaRoot = test.SetupGiteaRoot() - setting.CustomPath = filepath.Join(giteaRoot, "custom") - InitSettingsForTesting() + os.Exit(mainTest(m, testOptsArg...)) +} +func mainTest(m *testing.M, testOptsArg ...*TestOptions) int { + testOpts := util.OptionalArg(testOptsArg, &TestOptions{}) + InitSettingsForTesting() + giteaRoot := setting.GetGiteaTestSourceRoot() fixturesOpts := FixturesOptions{Dir: filepath.Join(giteaRoot, "models", "fixtures"), Files: testOpts.FixtureFiles} if err := CreateTestEngine(fixturesOpts); err != nil { - fatalTestError("Error creating test engine: %v\n", err) + testlogger.Panicf("Error creating test engine: %v\n", err) } - setting.IsInTesting = true setting.AppURL = "https://try.gitea.io/" setting.Domain = "try.gitea.io" setting.RunUser = "runuser" @@ -95,20 +93,18 @@ func MainTest(m *testing.M, testOptsArg ...*TestOptions) { setting.Repository.DefaultBranch = "master" // many test code still assume that default branch is called "master" repoRootPath, cleanup1, err := tempdir.OsTempDir("gitea-test").MkdirTempRandom("repos") if err != nil { - fatalTestError("TempDir: %v\n", err) + testlogger.Panicf("TempDir: %v\n", err) } defer cleanup1() setting.RepoRootPath = repoRootPath appDataPath, cleanup2, err := tempdir.OsTempDir("gitea-test").MkdirTempRandom("appdata") if err != nil { - fatalTestError("TempDir: %v\n", err) + testlogger.Panicf("TempDir: %v\n", err) } defer cleanup2() setting.AppDataPath = appDataPath - setting.AppWorkPath = giteaRoot - setting.StaticRootPath = giteaRoot setting.GravatarSource = "https://secure.gravatar.com/avatar/" setting.Attachment.Storage.Path = filepath.Join(setting.AppDataPath, "attachments") @@ -132,22 +128,22 @@ func MainTest(m *testing.M, testOptsArg ...*TestOptions) { config.SetDynGetter(system.NewDatabaseDynKeyGetter()) if err = cache.Init(); err != nil { - fatalTestError("cache.Init: %v\n", err) + testlogger.Panicf("cache.Init: %v\n", err) } if err = storage.Init(); err != nil { - fatalTestError("storage.Init: %v\n", err) + testlogger.Panicf("storage.Init: %v\n", err) } if err = SyncDirs(filepath.Join(giteaRoot, "tests", "gitea-repositories-meta"), setting.RepoRootPath); err != nil { - fatalTestError("util.SyncDirs: %v\n", err) + testlogger.Panicf("util.SyncDirs: %v\n", err) } if err = git.InitFull(); err != nil { - fatalTestError("git.Init: %v\n", err) + testlogger.Panicf("git.Init: %v\n", err) } if testOpts.SetUp != nil { if err := testOpts.SetUp(); err != nil { - fatalTestError("set up failed: %v\n", err) + testlogger.Panicf("set up failed: %v\n", err) } } @@ -155,10 +151,10 @@ func MainTest(m *testing.M, testOptsArg ...*TestOptions) { if testOpts.TearDown != nil { if err := testOpts.TearDown(); err != nil { - fatalTestError("tear down failed: %v\n", err) + testlogger.Panicf("tear down failed: %v\n", err) } } - os.Exit(exitStatus) + return exitStatus } // FixturesOptions fixtures needs to be loaded options @@ -172,7 +168,7 @@ func CreateTestEngine(opts FixturesOptions) error { x, err := xorm.NewEngine("sqlite3", "file::memory:?cache=shared&_txlock=immediate") if err != nil { if strings.Contains(err.Error(), "unknown driver") { - return fmt.Errorf(`sqlite3 requires: -tags sqlite,sqlite_unlock_notify%s%w`, "\n", err) + return fmt.Errorf("sqlite3 requires: -tags sqlite,sqlite_unlock_notify\n%w", err) } return err } @@ -182,7 +178,7 @@ func CreateTestEngine(opts FixturesOptions) error { if err = db.SyncAllTables(); err != nil { return err } - switch os.Getenv("GITEA_UNIT_TESTS_LOG_SQL") { + switch os.Getenv("GITEA_TEST_LOG_SQL") { case "true", "1": x.ShowSQL(true) } @@ -199,7 +195,6 @@ func PrepareTestDatabase() error { // by tests that use the above MainTest(..) function. func PrepareTestEnv(t testing.TB) { assert.NoError(t, PrepareTestDatabase()) - metaPath := filepath.Join(giteaRoot, "tests", "gitea-repositories-meta") + metaPath := filepath.Join(setting.GetGiteaTestSourceRoot(), "tests", "gitea-repositories-meta") assert.NoError(t, SyncDirs(metaPath, setting.RepoRootPath)) - test.SetupGiteaRoot() // Makes sure GITEA_ROOT is set } diff --git a/models/user/avatar.go b/models/user/avatar.go index 542bd93b982..5811d859117 100644 --- a/models/user/avatar.go +++ b/models/user/avatar.go @@ -30,22 +30,16 @@ func GenerateRandomAvatar(ctx context.Context, u *User) error { seed = u.Name } - img, err := avatar.RandomImage([]byte(seed)) - if err != nil { - return fmt.Errorf("RandomImage: %w", err) - } + img := avatar.RandomImageDefaultSize([]byte(seed)) u.Avatar = avatars.HashEmail(seed) - _, err = storage.Avatars.Stat(u.CustomAvatarRelativePath()) + _, err := storage.Avatars.Stat(u.CustomAvatarRelativePath()) if err != nil { // If unable to Stat the avatar file (usually it means non-existing), then try to save a new one // Don't share the images so that we can delete them easily if err := storage.SaveFrom(storage.Avatars, u.CustomAvatarRelativePath(), func(w io.Writer) error { - if err := png.Encode(w, img); err != nil { - log.Error("Encode: %v", err) - } - return nil + return png.Encode(w, img) }); err != nil { return fmt.Errorf("failed to save avatar %s: %w", u.CustomAvatarRelativePath(), err) } @@ -74,7 +68,7 @@ func (u *User) AvatarLinkWithSize(ctx context.Context, size int) string { switch { case u.UseCustomAvatar: useLocalAvatar = true - case disableGravatar, setting.OfflineMode: + case disableGravatar: useLocalAvatar = true autoGenerateAvatar = true } diff --git a/models/user/block.go b/models/user/block.go index 5f2b65a1990..f4afd47d0f7 100644 --- a/models/user/block.go +++ b/models/user/block.go @@ -90,7 +90,7 @@ func GetBlocking(ctx context.Context, blockerID, blockeeID int64) (*Blocking, er return nil, err } if len(blocks) == 0 { - return nil, nil + return nil, nil //nolint:nilnil // return nil to indicate that the object does not exist } return blocks[0], nil } diff --git a/models/user/email_address.go b/models/user/email_address.go index 67aa1bdd822..aa483d5f005 100644 --- a/models/user/email_address.go +++ b/models/user/email_address.go @@ -215,7 +215,7 @@ func GetEmailAddressByID(ctx context.Context, uid, id int64) (*EmailAddress, err if has, err := db.GetEngine(ctx).ID(id).Get(email); err != nil { return nil, err } else if !has { - return nil, nil + return nil, nil //nolint:nilnil // return nil to indicate that the object does not exist } return email, nil } @@ -276,17 +276,22 @@ func updateActivation(ctx context.Context, email *EmailAddress, activate bool) e return UpdateUserCols(ctx, user, "rands") } -func MakeActiveEmailPrimary(ctx context.Context, emailID int64) error { - return makeEmailPrimaryInternal(ctx, emailID, true) +func MakeActiveEmailPrimary(ctx context.Context, ownerID, emailID int64) error { + return makeEmailPrimaryInternal(ctx, ownerID, emailID, true) } -func MakeInactiveEmailPrimary(ctx context.Context, emailID int64) error { - return makeEmailPrimaryInternal(ctx, emailID, false) +func MakeInactiveEmailPrimary(ctx context.Context, ownerID, emailID int64) error { + return makeEmailPrimaryInternal(ctx, ownerID, emailID, false) } -func makeEmailPrimaryInternal(ctx context.Context, emailID int64, isActive bool) error { +func makeEmailPrimaryInternal(ctx context.Context, ownerID, emailID int64, isActive bool) error { email := &EmailAddress{} - if has, err := db.GetEngine(ctx).ID(emailID).Where(builder.Eq{"is_activated": isActive}).Get(email); err != nil { + if has, err := db.GetEngine(ctx).ID(emailID). + Where(builder.Eq{ + "uid": ownerID, + "is_activated": isActive, + }). + Get(email); err != nil { return err } else if !has { return ErrEmailAddressNotExist{} @@ -336,7 +341,7 @@ func ChangeInactivePrimaryEmail(ctx context.Context, uid int64, oldEmailAddr, ne if err != nil { return err } - return MakeInactiveEmailPrimary(ctx, newEmail.ID) + return MakeInactiveEmailPrimary(ctx, uid, newEmail.ID) }) } diff --git a/models/user/email_address_test.go b/models/user/email_address_test.go index 6ef18fb0f64..4167aaac0df 100644 --- a/models/user/email_address_test.go +++ b/models/user/email_address_test.go @@ -46,22 +46,22 @@ func TestIsEmailUsed(t *testing.T) { func TestMakeEmailPrimary(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - err := user_model.MakeActiveEmailPrimary(t.Context(), 9999999) + err := user_model.MakeActiveEmailPrimary(t.Context(), 1, 9999999) assert.Error(t, err) assert.ErrorIs(t, err, user_model.ErrEmailAddressNotExist{}) email := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{Email: "user11@example.com"}) - err = user_model.MakeActiveEmailPrimary(t.Context(), email.ID) + err = user_model.MakeActiveEmailPrimary(t.Context(), email.UID, email.ID) assert.Error(t, err) assert.ErrorIs(t, err, user_model.ErrEmailAddressNotExist{}) // inactive email is considered as not exist for "MakeActiveEmailPrimary" email = unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{Email: "user9999999@example.com"}) - err = user_model.MakeActiveEmailPrimary(t.Context(), email.ID) + err = user_model.MakeActiveEmailPrimary(t.Context(), email.UID, email.ID) assert.Error(t, err) assert.True(t, user_model.IsErrUserNotExist(err)) email = unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{Email: "user101@example.com"}) - err = user_model.MakeActiveEmailPrimary(t.Context(), email.ID) + err = user_model.MakeActiveEmailPrimary(t.Context(), email.UID, email.ID) assert.NoError(t, err) user, _ := user_model.GetUserByID(t.Context(), int64(10)) diff --git a/models/user/list.go b/models/user/list.go index ca589d1e020..aaaa7965c89 100644 --- a/models/user/list.go +++ b/models/user/list.go @@ -24,7 +24,7 @@ func (users UserList) GetUserIDs() []int64 { return userIDs } -// GetTwoFaStatus return state of 2FA enrollement +// GetTwoFaStatus return state of 2FA enrollment func (users UserList) GetTwoFaStatus(ctx context.Context) map[int64]bool { results := make(map[int64]bool, len(users)) for _, user := range users { @@ -48,7 +48,7 @@ func (users UserList) GetTwoFaStatus(ctx context.Context) map[int64]bool { func (users UserList) loadTwoFactorStatus(ctx context.Context) (map[int64]*auth.TwoFactor, error) { if len(users) == 0 { - return nil, nil + return nil, nil //nolint:nilnil // returns nil when there are no users } userIDs := users.GetUserIDs() diff --git a/models/user/openid.go b/models/user/openid.go index 420c67ca183..5baa48c8240 100644 --- a/models/user/openid.go +++ b/models/user/openid.go @@ -102,7 +102,13 @@ func DeleteUserOpenID(ctx context.Context, openid *UserOpenID) (err error) { } // ToggleUserOpenIDVisibility toggles visibility of an openid address of given user. -func ToggleUserOpenIDVisibility(ctx context.Context, id int64) (err error) { - _, err = db.GetEngine(ctx).Exec("update `user_open_id` set `show` = not `show` where `id` = ?", id) - return err +func ToggleUserOpenIDVisibility(ctx context.Context, id int64, user *User) error { + affected, err := db.GetEngine(ctx).Exec("update `user_open_id` set `show` = not `show` where `id` = ? AND uid = ?", id, user.ID) + if err != nil { + return err + } + if n, _ := affected.RowsAffected(); n != 1 { + return util.NewNotExistErrorf("OpenID is unknown") + } + return nil } diff --git a/models/user/openid_test.go b/models/user/openid_test.go index fa260e7a9ea..6d2260324fd 100644 --- a/models/user/openid_test.go +++ b/models/user/openid_test.go @@ -8,6 +8,7 @@ import ( "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -33,12 +34,14 @@ func TestGetUserOpenIDs(t *testing.T) { func TestToggleUserOpenIDVisibility(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) + user, err := user_model.GetUserByID(t.Context(), int64(2)) + require.NoError(t, err) oids, err := user_model.GetUserOpenIDs(t.Context(), int64(2)) require.NoError(t, err) require.Len(t, oids, 1) assert.True(t, oids[0].Show) - err = user_model.ToggleUserOpenIDVisibility(t.Context(), oids[0].ID) + err = user_model.ToggleUserOpenIDVisibility(t.Context(), oids[0].ID, user) require.NoError(t, err) oids, err = user_model.GetUserOpenIDs(t.Context(), int64(2)) @@ -46,7 +49,7 @@ func TestToggleUserOpenIDVisibility(t *testing.T) { require.Len(t, oids, 1) assert.False(t, oids[0].Show) - err = user_model.ToggleUserOpenIDVisibility(t.Context(), oids[0].ID) + err = user_model.ToggleUserOpenIDVisibility(t.Context(), oids[0].ID, user) require.NoError(t, err) oids, err = user_model.GetUserOpenIDs(t.Context(), int64(2)) @@ -55,3 +58,13 @@ func TestToggleUserOpenIDVisibility(t *testing.T) { assert.True(t, oids[0].Show) } } + +func TestToggleUserOpenIDVisibilityRequiresOwnership(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + unauthorizedUser, err := user_model.GetUserByID(t.Context(), int64(2)) + require.NoError(t, err) + + err = user_model.ToggleUserOpenIDVisibility(t.Context(), int64(1), unauthorizedUser) + require.Error(t, err) + assert.ErrorIs(t, err, util.ErrNotExist) +} diff --git a/models/user/search.go b/models/user/search.go index cfd0d011bcb..36d1d3913b6 100644 --- a/models/user/search.go +++ b/models/user/search.go @@ -6,6 +6,7 @@ package user import ( "context" "fmt" + "slices" "strings" "code.gitea.io/gitea/models/db" @@ -17,12 +18,29 @@ import ( "xorm.io/xorm" ) +// AdminUserOrderByMap represents all possible admin user search orders +// This should only be used for admin API endpoints as we should not expose "updated" ordering which could expose recent user activity including logins. +var AdminUserOrderByMap = map[string]map[string]db.SearchOrderBy{ + "asc": { + "name": db.SearchOrderByAlphabetically, + "created": db.SearchOrderByOldest, + "updated": db.SearchOrderByLeastUpdated, + "id": db.SearchOrderByID, + }, + "desc": { + "name": db.SearchOrderByAlphabeticallyReverse, + "created": db.SearchOrderByNewest, + "updated": db.SearchOrderByRecentUpdated, + "id": db.SearchOrderByIDReverse, + }, +} + // SearchUserOptions contains the options for searching type SearchUserOptions struct { db.ListOptions Keyword string - Type UserType + Types []UserType UID int64 LoginName string // this option should be used only for admin user SourceID int64 // this option should be used only for admin user @@ -43,16 +61,16 @@ type SearchUserOptions struct { func (opts *SearchUserOptions) toSearchQueryBase(ctx context.Context) *xorm.Session { var cond builder.Cond - cond = builder.Eq{"type": opts.Type} + cond = builder.In("type", opts.Types) if opts.IncludeReserved { - switch opts.Type { - case UserTypeIndividual: + switch { + case slices.Contains(opts.Types, UserTypeIndividual): cond = cond.Or(builder.Eq{"type": UserTypeUserReserved}).Or( builder.Eq{"type": UserTypeBot}, ).Or( builder.Eq{"type": UserTypeRemoteUser}, ) - case UserTypeOrganization: + case slices.Contains(opts.Types, UserTypeOrganization): cond = cond.Or(builder.Eq{"type": UserTypeOrganizationReserved}) } } diff --git a/models/user/setting_options.go b/models/user/setting_options.go index 922096234ce..acb34da3a5a 100644 --- a/models/user/setting_options.go +++ b/models/user/setting_options.go @@ -11,10 +11,6 @@ const ( // SettingsKeyShowOutdatedComments is the setting key whether or not to show outdated comments in PRs SettingsKeyShowOutdatedComments = "comment_code.show_outdated" - // UserActivityPubPrivPem is user's private key - UserActivityPubPrivPem = "activitypub.priv_pem" - // UserActivityPubPubPem is user's public key - UserActivityPubPubPem = "activitypub.pub_pem" // SignupIP is the IP address that the user signed up with SignupIP = "signup.ip" // SignupUserAgent is the user agent that the user signed up with diff --git a/models/user/user.go b/models/user/user.go index 6143992a253..a74662bb12a 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -8,11 +8,13 @@ import ( "context" "encoding/hex" "fmt" + "html/template" "mime" "net/mail" "net/url" "path/filepath" "regexp" + "strconv" "strings" "sync" "time" @@ -27,6 +29,7 @@ import ( "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/htmlutil" "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" @@ -184,7 +187,7 @@ func (u *User) BeforeUpdate() { } // FIXME: this email doesn't need to be in lowercase, because the emails are mainly managed by the email table with lower_email field - // This trick could be removed in new releases to display the user inputed email as-is. + // This trick could be removed in new releases to display the user inputted email as-is. u.Email = strings.ToLower(u.Email) if !u.IsOrganization() { if len(u.AvatarEmail) == 0 { @@ -212,7 +215,7 @@ func (u *User) SetLastLogin() { // GetPlaceholderEmail returns an noreply email func (u *User) GetPlaceholderEmail() string { - return fmt.Sprintf("%s@%s", u.LowerName, setting.Service.NoReplyAddress) + return fmt.Sprintf("%d+%s@%s", u.ID, u.LowerName, setting.Service.NoReplyAddress) } // GetEmail returns a noreply email, if the user has set to keep his @@ -249,8 +252,13 @@ func (u *User) MaxCreationLimit() int { } // CanCreateRepoIn checks whether the doer(u) can create a repository in the owner -// NOTE: functions calling this assume a failure due to repository count limit; it ONLY checks the repo number LIMIT, if new checks are added, those functions should be revised +// NOTE: functions calling this assume a failure due to repository count limit, or the owner is not a real user. +// It ONLY checks the repo number LIMIT or whether owner user is real. If new checks are added, those functions should be revised. +// TODO: the callers can only return ErrReachLimitOfRepo, need to fine tune to support other error types in the future. func (u *User) CanCreateRepoIn(owner *User) bool { + if u.ID <= 0 || owner.ID <= 0 { + return false // fake user like Ghost or Actions user + } if u.IsAdmin { return true } @@ -411,16 +419,6 @@ func (u *User) IsTokenAccessAllowed() bool { return u.Type == UserTypeIndividual || u.Type == UserTypeBot } -// DisplayName returns full name if it's not empty, -// returns username otherwise. -func (u *User) DisplayName() string { - trimmed := strings.TrimSpace(u.FullName) - if len(trimmed) > 0 { - return trimmed - } - return u.Name -} - // EmailTo returns a string suitable to be put into a e-mail `To:` header. func (u *User) EmailTo() string { sanitizedDisplayName := globalVars().emailToReplacer.Replace(u.DisplayName()) @@ -439,27 +437,45 @@ func (u *User) EmailTo() string { return fmt.Sprintf("%s <%s>", mime.QEncoding.Encode("utf-8", add.Name), add.Address) } -// GetDisplayName returns full name if it's not empty and DEFAULT_SHOW_FULL_NAME is set, -// returns username otherwise. +// TODO: DefaultShowFullName causes messy logic, there are already too many methods to display a user's "display name", need to refactor them +// * user.Name / user.FullName: directly used in templates +// * user.DisplayName(): always show FullName if it's not empty, otherwise show Name +// * user.GetDisplayName(): show FullName if it's not empty and DefaultShowFullName is set, otherwise show Name +// * user.ShortName(): used a lot in templates, but it should be removed and let frontend use "ellipsis" styles +// * activity action.ShortActUserName/GetActDisplayName/GetActDisplayNameTitle, etc: duplicate and messy + +// DisplayName returns full name if it's not empty, returns username otherwise. +func (u *User) DisplayName() string { + fullName := strings.TrimSpace(u.FullName) + if fullName != "" { + return fullName + } + return u.Name +} + +// GetDisplayName returns full name if it's not empty and DEFAULT_SHOW_FULL_NAME is set, otherwise, username. func (u *User) GetDisplayName() string { if setting.UI.DefaultShowFullName { - trimmed := strings.TrimSpace(u.FullName) - if len(trimmed) > 0 { - return trimmed + fullName := strings.TrimSpace(u.FullName) + if fullName != "" { + return fullName } } return u.Name } -// GetCompleteName returns the full name and username in the form of -// "Full Name (username)" if full name is not empty, otherwise it returns -// "username". -func (u *User) GetCompleteName() string { - trimmedFullName := strings.TrimSpace(u.FullName) - if len(trimmedFullName) > 0 { - return fmt.Sprintf("%s (%s)", trimmedFullName, u.Name) +// ShortName ellipses username to length (still used by many templates), it calls GetDisplayName and respects DEFAULT_SHOW_FULL_NAME +func (u *User) ShortName(length int) string { + return util.EllipsisDisplayString(u.GetDisplayName(), length) +} + +func (u *User) GetShortDisplayNameLinkHTML() template.HTML { + fullName := strings.TrimSpace(u.FullName) + displayName, displayTooltip := u.Name, fullName + if setting.UI.DefaultShowFullName && fullName != "" { + displayName, displayTooltip = fullName, u.Name } - return u.Name + return htmlutil.HTMLFormat(`%s`, u.HomeLink(), displayTooltip, displayName) } func gitSafeName(name string) string { @@ -482,18 +498,10 @@ func (u *User) GitName() string { return fmt.Sprintf("user-%d", u.ID) } -// ShortName ellipses username to length -func (u *User) ShortName(length int) string { - if setting.UI.DefaultShowFullName && len(u.FullName) > 0 { - return util.EllipsisDisplayString(u.FullName, length) - } - return util.EllipsisDisplayString(u.Name, length) -} - -// IsMailable checks if a user is eligible -// to receive emails. +// IsMailable checks if a user is eligible to receive emails. +// System users like Ghost and Gitea Actions are excluded. func (u *User) IsMailable() bool { - return u.IsActive + return u.IsActive && !u.IsGiteaActions() && !u.IsGhost() } // IsUserExist checks if given username exist, @@ -980,7 +988,7 @@ func GetInactiveUsers(ctx context.Context, olderThan time.Duration) ([]*User, er // UserPath returns the path absolute path of user repositories. func UserPath(userName string) string { //revive:disable-line:exported - return filepath.Join(setting.RepoRootPath, strings.ToLower(userName)) + return filepath.Join(setting.RepoRootPath, filepath.Clean(strings.ToLower(userName))) } // GetUserByID returns the user object by given ID if exists. @@ -1187,19 +1195,23 @@ func (eum *EmailUserMap) GetByEmail(email string) *User { func GetUsersByEmails(ctx context.Context, emails []string) (*EmailUserMap, error) { if len(emails) == 0 { - return nil, nil + return nil, nil //nolint:nilnil // return nil when there are no emails to look up } needCheckEmails := make(container.Set[string]) needCheckUserNames := make(container.Set[string]) + needCheckUserIDs := make(container.Set[int64]) noReplyAddressSuffix := "@" + strings.ToLower(setting.Service.NoReplyAddress) for _, email := range emails { emailLower := strings.ToLower(email) - if noReplyUserNameLower, ok := strings.CutSuffix(emailLower, noReplyAddressSuffix); ok { - needCheckUserNames.Add(noReplyUserNameLower) - needCheckEmails.Add(emailLower) - } else { - needCheckEmails.Add(emailLower) + needCheckEmails.Add(emailLower) + if localPart, ok := strings.CutSuffix(emailLower, noReplyAddressSuffix); ok { + name, id := parseLocalPartToNameID(localPart) + if id != 0 { + needCheckUserIDs.Add(id) + } else if name != "" { + needCheckUserNames.Add(name) + } } } @@ -1229,16 +1241,59 @@ func GetUsersByEmails(ctx context.Context, emails []string) (*EmailUserMap, erro } } - users := make(map[int64]*User, len(needCheckUserNames)) - if err := db.GetEngine(ctx).In("lower_name", needCheckUserNames.Values()).Find(&users); err != nil { - return nil, err + usersByIDs := make(map[int64]*User) + if len(needCheckUserIDs) > 0 || len(needCheckUserNames) > 0 { + cond := builder.NewCond() + if len(needCheckUserIDs) > 0 { + cond = cond.Or(builder.In("id", needCheckUserIDs.Values())) + } + if len(needCheckUserNames) > 0 { + cond = cond.Or(builder.In("lower_name", needCheckUserNames.Values())) + } + if err := db.GetEngine(ctx).Where(cond).Find(&usersByIDs); err != nil { + return nil, err + } } - for _, user := range users { - results[strings.ToLower(user.GetPlaceholderEmail())] = user + + usersByName := make(map[string]*User) + for _, user := range usersByIDs { + usersByName[user.LowerName] = user } + + for _, email := range emails { + emailLower := strings.ToLower(email) + if _, ok := results[emailLower]; ok { + continue + } + + localPart, ok := strings.CutSuffix(emailLower, noReplyAddressSuffix) + if !ok { + continue + } + name, id := parseLocalPartToNameID(localPart) + if user, ok := usersByIDs[id]; ok { + results[emailLower] = user + } else if user, ok := usersByName[name]; ok { + results[emailLower] = user + } + } + return &EmailUserMap{results}, nil } +// parseLocalPartToNameID attempts to unparse local-part of email that's in format id+user +// returns user and id if possible +func parseLocalPartToNameID(localPart string) (string, int64) { + var id int64 + idstr, name, hasPlus := strings.Cut(localPart, "+") + if hasPlus { + id, _ = strconv.ParseInt(idstr, 10, 64) + } else { + name = idstr + } + return name, id +} + // GetUserByEmail returns the user object by given e-mail if exists. func GetUserByEmail(ctx context.Context, email string) (*User, error) { if len(email) == 0 { @@ -1257,16 +1312,12 @@ func GetUserByEmail(ctx context.Context, email string) (*User, error) { } // Finally, if email address is the protected email address: - if strings.HasSuffix(email, "@"+setting.Service.NoReplyAddress) { - username := strings.TrimSuffix(email, "@"+setting.Service.NoReplyAddress) - user := &User{} - has, err := db.GetEngine(ctx).Where("lower_name=?", username).Get(user) - if err != nil { - return nil, err - } - if has { - return user, nil + if localPart, ok := strings.CutSuffix(email, strings.ToLower("@"+setting.Service.NoReplyAddress)); ok { + name, id := parseLocalPartToNameID(localPart) + if id != 0 { + return GetUserByID(ctx, id) } + return GetUserByName(ctx, name) } return nil, ErrUserNotExist{Name: email} @@ -1444,3 +1495,27 @@ func DisabledFeaturesWithLoginType(user *User) *container.Set[string] { } return &setting.Admin.UserDisabledFeatures } + +// GetUserOrOrgIDByName returns the id for a user or an org by name +func GetUserOrOrgIDByName(ctx context.Context, name string) (int64, error) { + var id int64 + has, err := db.GetEngine(ctx).Table("user").Where("name = ?", name).Cols("id").Get(&id) + if err != nil { + return 0, err + } else if !has { + return 0, fmt.Errorf("user or org with name %s: %w", name, util.ErrNotExist) + } + return id, nil +} + +// GetUserOrOrgByName returns the user or org by name +func GetUserOrOrgByName(ctx context.Context, name string) (*User, error) { + var u User + has, err := db.GetEngine(ctx).Where("lower_name = ?", strings.ToLower(name)).Get(&u) + if err != nil { + return nil, err + } else if !has { + return nil, ErrUserNotExist{Name: name} + } + return &u, nil +} diff --git a/models/user/user_system.go b/models/user/user_system.go index e07274d291e..cf4306df25d 100644 --- a/models/user/user_system.go +++ b/models/user/user_system.go @@ -4,6 +4,7 @@ package user import ( + "strconv" "strings" "code.gitea.io/gitea/modules/structs" @@ -23,10 +24,6 @@ func NewGhostUser() *User { } } -func IsGhostUserName(name string) bool { - return strings.EqualFold(name, GhostUserName) -} - // IsGhost check if user is fake user for a deleted account func (u *User) IsGhost() bool { if u == nil { @@ -41,36 +38,52 @@ const ( ActionsUserEmail = "teabot@gitea.io" ) -func IsGiteaActionsUserName(name string) bool { - return strings.EqualFold(name, ActionsUserName) -} - // NewActionsUser creates and returns a fake user for running the actions. func NewActionsUser() *User { return &User{ - ID: ActionsUserID, - Name: ActionsUserName, - LowerName: ActionsUserName, - IsActive: true, - FullName: "Gitea Actions", - Email: ActionsUserEmail, - KeepEmailPrivate: true, - LoginName: ActionsUserName, - Type: UserTypeBot, - AllowCreateOrganization: true, - Visibility: structs.VisibleTypePublic, + ID: ActionsUserID, + Name: ActionsUserName, + LowerName: ActionsUserName, + IsActive: true, + FullName: "Gitea Actions", + Email: ActionsUserEmail, + KeepEmailPrivate: true, + LoginName: ActionsUserName, + Type: UserTypeBot, + Visibility: structs.VisibleTypePublic, } } +func NewActionsUserWithTaskID(id int64) *User { + u := NewActionsUser() + // LoginName is for only internal usage in this case, so it can be moved to other fields in the future + u.LoginSource = -1 + u.LoginName = "@" + ActionsUserName + "/" + strconv.FormatInt(id, 10) + return u +} + +func GetActionsUserTaskID(u *User) (int64, bool) { + if u == nil || u.ID != ActionsUserID { + return 0, false + } + prefix, payload, _ := strings.Cut(u.LoginName, "/") + if prefix != "@"+ActionsUserName { + return 0, false + } else if taskID, err := strconv.ParseInt(payload, 10, 64); err == nil { + return taskID, true + } + return 0, false +} + func (u *User) IsGiteaActions() bool { return u != nil && u.ID == ActionsUserID } func GetSystemUserByName(name string) *User { - if IsGhostUserName(name) { + if strings.EqualFold(name, GhostUserName) { return NewGhostUser() } - if IsGiteaActionsUserName(name) { + if strings.EqualFold(name, ActionsUserName) { return NewActionsUser() } return nil diff --git a/models/user/user_system_test.go b/models/user/user_system_test.go index 5aa3fa463c9..70a900378f3 100644 --- a/models/user/user_system_test.go +++ b/models/user/user_system_test.go @@ -16,14 +16,20 @@ func TestSystemUser(t *testing.T) { assert.Equal(t, "Ghost", u.Name) assert.Equal(t, "ghost", u.LowerName) assert.True(t, u.IsGhost()) - assert.True(t, IsGhostUserName("gHost")) + + u = GetSystemUserByName("gHost") + require.NotNil(t, u) + assert.Equal(t, "Ghost", u.Name) u, err = GetPossibleUserByID(t.Context(), -2) require.NoError(t, err) assert.Equal(t, "gitea-actions", u.Name) assert.Equal(t, "gitea-actions", u.LowerName) assert.True(t, u.IsGiteaActions()) - assert.True(t, IsGiteaActionsUserName("Gitea-actionS")) + + u = GetSystemUserByName("Gitea-actionS") + require.NotNil(t, u) + assert.Equal(t, "Gitea Actions", u.FullName) _, err = GetPossibleUserByID(t.Context(), -3) require.Error(t, err) diff --git a/models/user/user_test.go b/models/user/user_test.go index 4201ec4816c..956eaeafb4f 100644 --- a/models/user/user_test.go +++ b/models/user/user_test.go @@ -51,12 +51,27 @@ func TestOAuth2Application_LoadUser(t *testing.T) { func TestUserEmails(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) + defer test.MockVariableValue(&setting.Service.NoReplyAddress, "NoReply.gitea.internal")() t.Run("GetUserEmailsByNames", func(t *testing.T) { - // ignore none active user email + // ignore not active user email assert.ElementsMatch(t, []string{"user8@example.com"}, user_model.GetUserEmailsByNames(t.Context(), []string{"user8", "user9"})) assert.ElementsMatch(t, []string{"user8@example.com", "user5@example.com"}, user_model.GetUserEmailsByNames(t.Context(), []string{"user8", "user5"})) assert.ElementsMatch(t, []string{"user8@example.com"}, user_model.GetUserEmailsByNames(t.Context(), []string{"user8", "org7"})) }) + + cases := []struct { + Email string + UID int64 + }{ + {"UseR1@example.com", 1}, + {"user1-2@example.COM", 1}, + {"USER2@" + setting.Service.NoReplyAddress, 2}, + {"2+user2@" + setting.Service.NoReplyAddress, 2}, + {"2+oldUser2UsernameWhichDoesNotMatterForQuery@" + setting.Service.NoReplyAddress, 2}, + {"99999+badUser@" + setting.Service.NoReplyAddress, 0}, + {"user4@example.com", 4}, + {"no-such", 0}, + } t.Run("GetUsersByEmails", func(t *testing.T) { defer test.MockVariableValue(&setting.Service.NoReplyAddress, "NoReply.gitea.internal")() testGetUserByEmail := func(t *testing.T, email string, uid int64) { @@ -70,15 +85,27 @@ func TestUserEmails(t *testing.T) { require.NotNil(t, user) assert.Equal(t, uid, user.ID) } - cases := []struct { - Email string - UID int64 - }{ - {"UseR1@example.com", 1}, - {"user1-2@example.COM", 1}, - {"USER2@" + setting.Service.NoReplyAddress, 2}, - {"user4@example.com", 4}, - {"no-such", 0}, + for _, c := range cases { + t.Run(c.Email, func(t *testing.T) { + testGetUserByEmail(t, c.Email, c.UID) + }) + } + + t.Run("NoReplyConflict", func(t *testing.T) { + setting.Service.NoReplyAddress = "example.com" + testGetUserByEmail(t, "user1-2@example.COM", 1) + }) + }) + t.Run("GetUserByEmail", func(t *testing.T) { + testGetUserByEmail := func(t *testing.T, email string, uid int64) { + user, err := user_model.GetUserByEmail(t.Context(), email) + if uid == 0 { + require.Error(t, err) + assert.Nil(t, user) + } else { + require.NotNil(t, user) + assert.Equal(t, uid, user.ID) + } } for _, c := range cases { t.Run(c.Email, func(t *testing.T) { @@ -126,7 +153,7 @@ func TestSearchUsers(t *testing.T) { // test orgs testOrgSuccess := func(opts user_model.SearchUserOptions, expectedOrgIDs []int64) { - opts.Type = user_model.UserTypeOrganization + opts.Types = []user_model.UserType{user_model.UserTypeOrganization} testSuccess(opts, expectedOrgIDs) } @@ -150,7 +177,7 @@ func TestSearchUsers(t *testing.T) { // test users testUserSuccess := func(opts user_model.SearchUserOptions, expectedUserIDs []int64) { - opts.Type = user_model.UserTypeIndividual + opts.Types = []user_model.UserType{user_model.UserTypeIndividual} testSuccess(opts, expectedUserIDs) } @@ -648,33 +675,36 @@ func TestGetInactiveUsers(t *testing.T) { func TestCanCreateRepo(t *testing.T) { defer test.MockVariableValue(&setting.Repository.MaxCreationLimit)() const noLimit = -1 - doerNormal := &user_model.User{} - doerAdmin := &user_model.User{IsAdmin: true} + doerActions := user_model.NewActionsUser() + doerNormal := &user_model.User{ID: 2} + doerAdmin := &user_model.User{ID: 1, IsAdmin: true} t.Run("NoGlobalLimit", func(t *testing.T) { setting.Repository.MaxCreationLimit = noLimit - assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: noLimit})) - assert.False(t, doerNormal.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: 0})) - assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: 100})) + assert.False(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: 0})) + assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: 100})) + assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: noLimit})) - assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: noLimit})) - assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: 0})) - assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: 100})) + assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: 0})) + assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: 100})) + assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: noLimit})) + assert.False(t, doerActions.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: noLimit})) + assert.False(t, doerAdmin.CanCreateRepoIn(doerActions)) }) t.Run("GlobalLimit50", func(t *testing.T) { setting.Repository.MaxCreationLimit = 50 - assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: noLimit})) - assert.False(t, doerNormal.CanCreateRepoIn(&user_model.User{NumRepos: 60, MaxRepoCreation: noLimit})) // limited by global limit - assert.False(t, doerNormal.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: 0})) - assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: 100})) - assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{NumRepos: 60, MaxRepoCreation: 100})) + assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: noLimit})) + assert.False(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 60, MaxRepoCreation: noLimit})) // limited by global limit + assert.False(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: 0})) + assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: 100})) + assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 60, MaxRepoCreation: 100})) - assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: noLimit})) - assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{NumRepos: 60, MaxRepoCreation: noLimit})) - assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: 0})) - assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{NumRepos: 10, MaxRepoCreation: 100})) - assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{NumRepos: 60, MaxRepoCreation: 100})) + assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: noLimit})) + assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 60, MaxRepoCreation: noLimit})) + assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: 0})) + assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: 100})) + assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 60, MaxRepoCreation: 100})) }) } diff --git a/models/webhook/webhook_system.go b/models/webhook/webhook_system.go index 58d9d4a5c1b..e8b5040c96f 100644 --- a/models/webhook/webhook_system.go +++ b/models/webhook/webhook_system.go @@ -9,19 +9,32 @@ import ( "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/optional" + + "xorm.io/builder" ) -// GetSystemOrDefaultWebhooks returns webhooks by given argument or all if argument is missing. -func GetSystemOrDefaultWebhooks(ctx context.Context, isSystemWebhook optional.Option[bool]) ([]*Webhook, error) { - webhooks := make([]*Webhook, 0, 5) - if !isSystemWebhook.Has() { - return webhooks, db.GetEngine(ctx).Where("repo_id=? AND owner_id=?", 0, 0). - Find(&webhooks) - } +// ListSystemWebhookOptions options for listing system or default webhooks +type ListSystemWebhookOptions struct { + db.ListOptions + IsActive optional.Option[bool] + IsSystem optional.Option[bool] +} - return webhooks, db.GetEngine(ctx). - Where("repo_id=? AND owner_id=? AND is_system_webhook=?", 0, 0, isSystemWebhook.Value()). - Find(&webhooks) +func (opts ListSystemWebhookOptions) ToConds() builder.Cond { + cond := builder.NewCond() + cond = cond.And(builder.Eq{"webhook.repo_id": 0}, builder.Eq{"webhook.owner_id": 0}) + if opts.IsActive.Has() { + cond = cond.And(builder.Eq{"webhook.is_active": opts.IsActive.Value()}) + } + if opts.IsSystem.Has() { + cond = cond.And(builder.Eq{"is_system_webhook": opts.IsSystem.Value()}) + } + return cond +} + +// GetGlobalWebhooks returns global (default and/or system) webhooks +func GetGlobalWebhooks(ctx context.Context, opts *ListSystemWebhookOptions) ([]*Webhook, int64, error) { + return db.FindAndCount[Webhook](ctx, opts) } // GetDefaultWebhooks returns all admin-default webhooks. diff --git a/models/webhook/webhook_system_test.go b/models/webhook/webhook_system_test.go index 8aac693995d..d0013c6873f 100644 --- a/models/webhook/webhook_system_test.go +++ b/models/webhook/webhook_system_test.go @@ -12,23 +12,24 @@ import ( "github.com/stretchr/testify/assert" ) -func TestGetSystemOrDefaultWebhooks(t *testing.T) { +func TestListSystemWebhookOptions(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - - hooks, err := GetSystemOrDefaultWebhooks(t.Context(), optional.None[bool]()) + opts := ListSystemWebhookOptions{IsSystem: optional.None[bool]()} + hooks, _, err := GetGlobalWebhooks(t.Context(), &opts) assert.NoError(t, err) if assert.Len(t, hooks, 2) { assert.Equal(t, int64(5), hooks[0].ID) assert.Equal(t, int64(6), hooks[1].ID) } - - hooks, err = GetSystemOrDefaultWebhooks(t.Context(), optional.Some(true)) + opts.IsSystem = optional.Some(true) + hooks, _, err = GetGlobalWebhooks(t.Context(), &opts) assert.NoError(t, err) if assert.Len(t, hooks, 1) { assert.Equal(t, int64(5), hooks[0].ID) } - hooks, err = GetSystemOrDefaultWebhooks(t.Context(), optional.Some(false)) + opts.IsSystem = optional.Some(false) + hooks, _, err = GetGlobalWebhooks(t.Context(), &opts) assert.NoError(t, err) if assert.Len(t, hooks, 1) { assert.Equal(t, int64(6), hooks[0].ID) diff --git a/modules/actions/jobparser/evaluator.go b/modules/actions/jobparser/evaluator.go new file mode 100644 index 00000000000..363cb2c18bc --- /dev/null +++ b/modules/actions/jobparser/evaluator.go @@ -0,0 +1,189 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package jobparser + +import ( + "errors" + "fmt" + "regexp" + "strings" + + "github.com/nektos/act/pkg/exprparser" + "go.yaml.in/yaml/v4" +) + +// ExpressionEvaluator is copied from runner.expressionEvaluator, +// to avoid unnecessary dependencies +type ExpressionEvaluator struct { + interpreter exprparser.Interpreter +} + +func NewExpressionEvaluator(interpreter exprparser.Interpreter) *ExpressionEvaluator { + return &ExpressionEvaluator{interpreter: interpreter} +} + +func (ee ExpressionEvaluator) evaluate(in string, defaultStatusCheck exprparser.DefaultStatusCheck) (any, error) { + evaluated, err := ee.interpreter.Evaluate(in, defaultStatusCheck) + + return evaluated, err +} + +func (ee ExpressionEvaluator) evaluateScalarYamlNode(node *yaml.Node) error { + var in string + if err := node.Decode(&in); err != nil { + return err + } + if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") { + return nil + } + expr, _ := rewriteSubExpression(in, false) + res, err := ee.evaluate(expr, exprparser.DefaultStatusCheckNone) + if err != nil { + return err + } + return node.Encode(res) +} + +func (ee ExpressionEvaluator) evaluateMappingYamlNode(node *yaml.Node) error { + // GitHub has this undocumented feature to merge maps, called insert directive + insertDirective := regexp.MustCompile(`\${{\s*insert\s*}}`) + for i := 0; i < len(node.Content)/2; { + k := node.Content[i*2] + v := node.Content[i*2+1] + if err := ee.EvaluateYamlNode(v); err != nil { + return err + } + var sk string + // Merge the nested map of the insert directive + if k.Decode(&sk) == nil && insertDirective.MatchString(sk) { + node.Content = append(append(node.Content[:i*2], v.Content...), node.Content[(i+1)*2:]...) + i += len(v.Content) / 2 + } else { + if err := ee.EvaluateYamlNode(k); err != nil { + return err + } + i++ + } + } + return nil +} + +func (ee ExpressionEvaluator) evaluateSequenceYamlNode(node *yaml.Node) error { + for i := 0; i < len(node.Content); { + v := node.Content[i] + // Preserve nested sequences + wasseq := v.Kind == yaml.SequenceNode + if err := ee.EvaluateYamlNode(v); err != nil { + return err + } + // GitHub has this undocumented feature to merge sequences / arrays + // We have a nested sequence via evaluation, merge the arrays + if v.Kind == yaml.SequenceNode && !wasseq { + node.Content = append(append(node.Content[:i], v.Content...), node.Content[i+1:]...) + i += len(v.Content) + } else { + i++ + } + } + return nil +} + +func (ee ExpressionEvaluator) EvaluateYamlNode(node *yaml.Node) error { + switch node.Kind { + case yaml.ScalarNode: + return ee.evaluateScalarYamlNode(node) + case yaml.MappingNode: + return ee.evaluateMappingYamlNode(node) + case yaml.SequenceNode: + return ee.evaluateSequenceYamlNode(node) + default: + return nil + } +} + +func (ee ExpressionEvaluator) Interpolate(in string) string { + if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") { + return in + } + + expr, _ := rewriteSubExpression(in, true) + evaluated, err := ee.evaluate(expr, exprparser.DefaultStatusCheckNone) + if err != nil { + return "" + } + + value, ok := evaluated.(string) + if !ok { + panic(fmt.Sprintf("Expression %s did not evaluate to a string", expr)) + } + + return value +} + +func escapeFormatString(in string) string { + return strings.ReplaceAll(strings.ReplaceAll(in, "{", "{{"), "}", "}}") +} + +func rewriteSubExpression(in string, forceFormat bool) (string, error) { + if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") { + return in, nil + } + + strPattern := regexp.MustCompile("(?:''|[^'])*'") + pos := 0 + exprStart := -1 + strStart := -1 + var results []string + formatOut := "" + for pos < len(in) { + if strStart > -1 { + matches := strPattern.FindStringIndex(in[pos:]) + if matches == nil { + return "", errors.New("unclosed string") + } + + strStart = -1 + pos += matches[1] + } else if exprStart > -1 { + exprEnd := strings.Index(in[pos:], "}}") + strStart = strings.Index(in[pos:], "'") + + if exprEnd > -1 && strStart > -1 { + if exprEnd < strStart { + strStart = -1 + } else { + exprEnd = -1 + } + } + + if exprEnd > -1 { + formatOut += fmt.Sprintf("{%d}", len(results)) + results = append(results, strings.TrimSpace(in[exprStart:pos+exprEnd])) + pos += exprEnd + 2 + exprStart = -1 + } else if strStart > -1 { + pos += strStart + 1 + } else { + panic("unclosed expression.") + } + } else { + exprStart = strings.Index(in[pos:], "${{") + if exprStart != -1 { + formatOut += escapeFormatString(in[pos : pos+exprStart]) + exprStart = pos + exprStart + 3 + pos = exprStart + } else { + formatOut += escapeFormatString(in[pos:]) + pos = len(in) + } + } + } + + if len(results) == 1 && formatOut == "{0}" && !forceFormat { + return in, nil + } + + out := fmt.Sprintf("format('%s', %s)", strings.ReplaceAll(formatOut, "'", "''"), strings.Join(results, ", ")) + return out, nil +} diff --git a/modules/actions/jobparser/interpeter.go b/modules/actions/jobparser/interpeter.go new file mode 100644 index 00000000000..512b6f02ab6 --- /dev/null +++ b/modules/actions/jobparser/interpeter.go @@ -0,0 +1,87 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package jobparser + +import ( + "github.com/nektos/act/pkg/exprparser" + "github.com/nektos/act/pkg/model" + "go.yaml.in/yaml/v4" +) + +// NewInterpeter returns an interpeter used in the server, +// need github, needs, strategy, matrix, inputs context only, +// see https://docs.github.com/en/actions/learn-github-actions/contexts#context-availability +func NewInterpeter( + jobID string, + job *model.Job, + matrix map[string]any, + gitCtx *model.GithubContext, + results map[string]*JobResult, + vars map[string]string, + inputs map[string]any, +) exprparser.Interpreter { + strategy := make(map[string]any) + if job.Strategy != nil { + strategy["fail-fast"] = job.Strategy.FailFast + strategy["max-parallel"] = job.Strategy.MaxParallel + } + + run := &model.Run{ + Workflow: &model.Workflow{ + Jobs: map[string]*model.Job{}, + }, + JobID: jobID, + } + for id, result := range results { + need := yaml.Node{} + _ = need.Encode(result.Needs) + run.Workflow.Jobs[id] = &model.Job{ + RawNeeds: need, + Result: result.Result, + Outputs: result.Outputs, + } + } + + jobs := run.Workflow.Jobs + jobNeeds := run.Job().Needs() + + using := map[string]exprparser.Needs{} + for _, need := range jobNeeds { + if v, ok := jobs[need]; ok { + using[need] = exprparser.Needs{ + Outputs: v.Outputs, + Result: v.Result, + } + } + } + + ee := &exprparser.EvaluationEnvironment{ + Github: gitCtx, + Env: nil, // no need + Job: nil, // no need + Steps: nil, // no need + Runner: nil, // no need + Secrets: nil, // no need + Strategy: strategy, + Matrix: matrix, + Needs: using, + Inputs: inputs, + Vars: vars, + } + + config := exprparser.Config{ + Run: run, + WorkingDir: "", // WorkingDir is used for the function hashFiles, but it's not needed in the server + Context: "job", + } + + return exprparser.NewInterpeter(ee, config) +} + +// JobResult is the minimum requirement of job results for Interpeter +type JobResult struct { + Needs []string + Result string + Outputs map[string]string +} diff --git a/modules/actions/jobparser/jobparser.go b/modules/actions/jobparser/jobparser.go new file mode 100644 index 00000000000..1d4c4c1756a --- /dev/null +++ b/modules/actions/jobparser/jobparser.go @@ -0,0 +1,177 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package jobparser + +import ( + "bytes" + "fmt" + "sort" + "strings" + + "github.com/nektos/act/pkg/exprparser" + "github.com/nektos/act/pkg/model" + "go.yaml.in/yaml/v4" +) + +func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) { + origin, err := model.ReadWorkflow(bytes.NewReader(content)) + if err != nil { + return nil, fmt.Errorf("model.ReadWorkflow: %w", err) + } + + workflow := &SingleWorkflow{} + if err := yaml.Unmarshal(content, workflow); err != nil { + return nil, fmt.Errorf("yaml.Unmarshal: %w", err) + } + + pc := &parseContext{} + for _, o := range options { + o(pc) + } + results := map[string]*JobResult{} + for id, job := range origin.Jobs { + results[id] = &JobResult{ + Needs: job.Needs(), + Result: pc.jobResults[id], + Outputs: nil, // not supported yet + } + } + + var ret []*SingleWorkflow + ids, jobs, err := workflow.jobs() + if err != nil { + return nil, fmt.Errorf("invalid jobs: %w", err) + } + + evaluator := NewExpressionEvaluator(exprparser.NewInterpeter(&exprparser.EvaluationEnvironment{Github: pc.gitContext, Vars: pc.vars, Inputs: pc.inputs}, exprparser.Config{})) + workflow.RunName = evaluator.Interpolate(workflow.RunName) + + for i, id := range ids { + job := jobs[i] + matricxes, err := getMatrixes(origin.GetJob(id)) + if err != nil { + return nil, fmt.Errorf("getMatrixes: %w", err) + } + for _, matrix := range matricxes { + job := job.Clone() + if job.Name == "" { + job.Name = id + } + job.Strategy.RawMatrix = encodeMatrix(matrix) + evaluator := NewExpressionEvaluator(NewInterpeter(id, origin.GetJob(id), matrix, pc.gitContext, results, pc.vars, pc.inputs)) + job.Name = nameWithMatrix(job.Name, matrix, evaluator) + runsOn := origin.GetJob(id).RunsOn() + for i, v := range runsOn { + runsOn[i] = evaluator.Interpolate(v) + } + job.RawRunsOn = encodeRunsOn(runsOn) + swf := &SingleWorkflow{ + Name: workflow.Name, + RawOn: workflow.RawOn, + Env: workflow.Env, + Defaults: workflow.Defaults, + RawPermissions: workflow.RawPermissions, + RunName: workflow.RunName, + } + if err := swf.SetJob(id, job); err != nil { + return nil, fmt.Errorf("SetJob: %w", err) + } + ret = append(ret, swf) + } + } + return ret, nil +} + +func WithJobResults(results map[string]string) ParseOption { + return func(c *parseContext) { + c.jobResults = results + } +} + +func WithGitContext(context *model.GithubContext) ParseOption { + return func(c *parseContext) { + c.gitContext = context + } +} + +func WithVars(vars map[string]string) ParseOption { + return func(c *parseContext) { + c.vars = vars + } +} + +func WithInputs(inputs map[string]any) ParseOption { + return func(c *parseContext) { + c.inputs = inputs + } +} + +type parseContext struct { + jobResults map[string]string + gitContext *model.GithubContext + vars map[string]string + inputs map[string]any +} + +type ParseOption func(c *parseContext) + +func getMatrixes(job *model.Job) ([]map[string]any, error) { + ret, err := job.GetMatrixes() + if err != nil { + return nil, fmt.Errorf("GetMatrixes: %w", err) + } + sort.Slice(ret, func(i, j int) bool { + return matrixName(ret[i]) < matrixName(ret[j]) + }) + return ret, nil +} + +func encodeMatrix(matrix map[string]any) yaml.Node { + if len(matrix) == 0 { + return yaml.Node{} + } + value := map[string][]any{} + for k, v := range matrix { + value[k] = []any{v} + } + node := yaml.Node{} + _ = node.Encode(value) + return node +} + +func encodeRunsOn(runsOn []string) yaml.Node { + node := yaml.Node{} + if len(runsOn) == 1 { + _ = node.Encode(runsOn[0]) + } else { + _ = node.Encode(runsOn) + } + return node +} + +func nameWithMatrix(name string, m map[string]any, evaluator *ExpressionEvaluator) string { + if len(m) == 0 { + return name + } + + if !strings.Contains(name, "${{") || !strings.Contains(name, "}}") { + return name + " " + matrixName(m) + } + + return evaluator.Interpolate(name) +} + +func matrixName(m map[string]any) string { + ks := make([]string, 0, len(m)) + for k := range m { + ks = append(ks, k) + } + sort.Strings(ks) + vs := make([]string, 0, len(m)) + for _, v := range ks { + vs = append(vs, fmt.Sprint(m[v])) + } + + return fmt.Sprintf("(%s)", strings.Join(vs, ", ")) +} diff --git a/modules/actions/jobparser/jobparser_test.go b/modules/actions/jobparser/jobparser_test.go new file mode 100644 index 00000000000..51ba70fc2ad --- /dev/null +++ b/modules/actions/jobparser/jobparser_test.go @@ -0,0 +1,87 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package jobparser + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v4" +) + +func TestParse(t *testing.T) { + tests := []struct { + name string + options []ParseOption + wantErr bool + }{ + { + name: "multiple_jobs", + options: nil, + wantErr: false, + }, + { + name: "multiple_matrix", + options: nil, + wantErr: false, + }, + { + name: "has_needs", + options: nil, + wantErr: false, + }, + { + name: "has_with", + options: nil, + wantErr: false, + }, + { + name: "has_secrets", + options: nil, + wantErr: false, + }, + { + name: "empty_step", + options: nil, + wantErr: false, + }, + { + name: "job_name_with_matrix", + options: nil, + wantErr: false, + }, + { + name: "prefixed_newline", + options: nil, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + content := ReadTestdata(t, tt.name+".in.yaml") + want := ReadTestdata(t, tt.name+".out.yaml") + got, err := Parse(content, tt.options...) + if tt.wantErr { + require.Error(t, err) + } + require.NoError(t, err) + + builder := &strings.Builder{} + for _, v := range got { + if builder.Len() > 0 { + builder.WriteString("---\n") + } + encoder := yaml.NewEncoder(builder) + encoder.SetIndent(2) + require.NoError(t, encoder.Encode(v)) + id, job := v.Job() + assert.NotEmpty(t, id) + assert.NotNil(t, job) + } + assert.Equal(t, string(want), builder.String()) + }) + } +} diff --git a/modules/actions/jobparser/model.go b/modules/actions/jobparser/model.go new file mode 100644 index 00000000000..7132c278e95 --- /dev/null +++ b/modules/actions/jobparser/model.go @@ -0,0 +1,497 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package jobparser + +import ( + "bytes" + "errors" + "fmt" + + "github.com/nektos/act/pkg/model" + "go.yaml.in/yaml/v4" +) + +// SingleWorkflow is a workflow with single job and single matrix +type SingleWorkflow struct { + Name string `yaml:"name,omitempty"` + RawOn yaml.Node `yaml:"on,omitempty"` + Env map[string]string `yaml:"env,omitempty"` + RawJobs yaml.Node `yaml:"jobs,omitempty"` + Defaults Defaults `yaml:"defaults,omitempty"` + RawPermissions yaml.Node `yaml:"permissions,omitempty"` + RunName string `yaml:"run-name,omitempty"` +} + +func (w *SingleWorkflow) Job() (string, *Job) { + ids, jobs, _ := w.jobs() + if len(ids) >= 1 { + return ids[0], jobs[0] + } + return "", nil +} + +func (w *SingleWorkflow) jobs() ([]string, []*Job, error) { + ids, jobs, err := parseMappingNode[*Job](&w.RawJobs) + if err != nil { + return nil, nil, err + } + + for _, job := range jobs { + steps := make([]*Step, 0, len(job.Steps)) + for _, s := range job.Steps { + if s != nil { + steps = append(steps, s) + } + } + job.Steps = steps + } + + return ids, jobs, nil +} + +func (w *SingleWorkflow) SetJob(id string, job *Job) error { + m := map[string]*Job{ + id: job, + } + var buf bytes.Buffer + encoder := yaml.NewEncoder(&buf) + encoder.SetIndent(2) + if err := encoder.Encode(m); err != nil { + return err + } + encoder.Close() + + node := yaml.Node{} + if err := yaml.Unmarshal(buf.Bytes(), &node); err != nil { + return err + } + if len(node.Content) != 1 || node.Content[0].Kind != yaml.MappingNode { + return fmt.Errorf("can not set job: %s", buf.String()) + } + w.RawJobs = *node.Content[0] + return nil +} + +func (w *SingleWorkflow) Marshal() ([]byte, error) { + return yaml.Marshal(w) +} + +type Job struct { + Name string `yaml:"name,omitempty"` + RawNeeds yaml.Node `yaml:"needs,omitempty"` + RawRunsOn yaml.Node `yaml:"runs-on,omitempty"` + Env yaml.Node `yaml:"env,omitempty"` + If yaml.Node `yaml:"if,omitempty"` + Steps []*Step `yaml:"steps,omitempty"` + TimeoutMinutes string `yaml:"timeout-minutes,omitempty"` + Services map[string]*ContainerSpec `yaml:"services,omitempty"` + Strategy Strategy `yaml:"strategy,omitempty"` + RawContainer yaml.Node `yaml:"container,omitempty"` + Defaults Defaults `yaml:"defaults,omitempty"` + Outputs map[string]string `yaml:"outputs,omitempty"` + Uses string `yaml:"uses,omitempty"` + With map[string]any `yaml:"with,omitempty"` + RawSecrets yaml.Node `yaml:"secrets,omitempty"` + RawConcurrency *model.RawConcurrency `yaml:"concurrency,omitempty"` + RawPermissions yaml.Node `yaml:"permissions,omitempty"` +} + +func (j *Job) Clone() *Job { + if j == nil { + return nil + } + return &Job{ + Name: j.Name, + RawNeeds: j.RawNeeds, + RawRunsOn: j.RawRunsOn, + Env: j.Env, + If: j.If, + Steps: j.Steps, + TimeoutMinutes: j.TimeoutMinutes, + Services: j.Services, + Strategy: j.Strategy, + RawContainer: j.RawContainer, + Defaults: j.Defaults, + Outputs: j.Outputs, + Uses: j.Uses, + With: j.With, + RawSecrets: j.RawSecrets, + RawConcurrency: j.RawConcurrency, + RawPermissions: j.RawPermissions, + } +} + +func (j *Job) Needs() []string { + return (&model.Job{RawNeeds: j.RawNeeds}).Needs() +} + +func (j *Job) EraseNeeds() *Job { + j.RawNeeds = yaml.Node{} + return j +} + +func (j *Job) RunsOn() []string { + return (&model.Job{RawRunsOn: j.RawRunsOn}).RunsOn() +} + +type Step struct { + ID string `yaml:"id,omitempty"` + If yaml.Node `yaml:"if,omitempty"` + Name string `yaml:"name,omitempty"` + Uses string `yaml:"uses,omitempty"` + Run string `yaml:"run,omitempty"` + WorkingDirectory string `yaml:"working-directory,omitempty"` + Shell string `yaml:"shell,omitempty"` + Env yaml.Node `yaml:"env,omitempty"` + With map[string]string `yaml:"with,omitempty"` + ContinueOnError bool `yaml:"continue-on-error,omitempty"` + TimeoutMinutes string `yaml:"timeout-minutes,omitempty"` +} + +// String gets the name of step +func (s *Step) String() string { + if s == nil { + return "" + } + return (&model.Step{ + ID: s.ID, + Name: s.Name, + Uses: s.Uses, + Run: s.Run, + }).String() +} + +type ContainerSpec struct { + Image string `yaml:"image,omitempty"` + Env map[string]string `yaml:"env,omitempty"` + Ports []string `yaml:"ports,omitempty"` + Volumes []string `yaml:"volumes,omitempty"` + Options string `yaml:"options,omitempty"` + Credentials map[string]string `yaml:"credentials,omitempty"` + Cmd []string `yaml:"cmd,omitempty"` +} + +type Strategy struct { + FailFastString string `yaml:"fail-fast,omitempty"` + MaxParallelString string `yaml:"max-parallel,omitempty"` + RawMatrix yaml.Node `yaml:"matrix,omitempty"` +} + +type Defaults struct { + Run RunDefaults `yaml:"run,omitempty"` +} + +type RunDefaults struct { + Shell string `yaml:"shell,omitempty"` + WorkingDirectory string `yaml:"working-directory,omitempty"` +} + +type WorkflowDispatchInput struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Required bool `yaml:"required"` + Default string `yaml:"default"` + Type string `yaml:"type"` + Options []string `yaml:"options"` +} + +type Event struct { + Name string + acts map[string][]string + schedules []map[string]string + inputs []WorkflowDispatchInput +} + +func (evt *Event) IsSchedule() bool { + return evt.schedules != nil +} + +func (evt *Event) Acts() map[string][]string { + return evt.acts +} + +func (evt *Event) Schedules() []map[string]string { + return evt.schedules +} + +func (evt *Event) Inputs() []WorkflowDispatchInput { + return evt.inputs +} + +func ReadWorkflowRawConcurrency(content []byte) (*model.RawConcurrency, error) { + w := new(model.Workflow) + err := yaml.NewDecoder(bytes.NewReader(content)).Decode(w) + return w.RawConcurrency, err +} + +func EvaluateConcurrency(rc *model.RawConcurrency, jobID string, job *Job, gitCtx map[string]any, results map[string]*JobResult, vars map[string]string, inputs map[string]any) (string, bool, error) { + actJob := &model.Job{} + if job != nil { + actJob.Strategy = &model.Strategy{ + FailFastString: job.Strategy.FailFastString, + MaxParallelString: job.Strategy.MaxParallelString, + RawMatrix: job.Strategy.RawMatrix, + } + actJob.Strategy.FailFast = actJob.Strategy.GetFailFast() + actJob.Strategy.MaxParallel = actJob.Strategy.GetMaxParallel() + } + + matrix := make(map[string]any) + matrixes, err := actJob.GetMatrixes() + if err != nil { + return "", false, err + } + if len(matrixes) > 0 { + matrix = matrixes[0] + } + + evaluator := NewExpressionEvaluator(NewInterpeter(jobID, actJob, matrix, toGitContext(gitCtx), results, vars, inputs)) + var node yaml.Node + if err := node.Encode(rc); err != nil { + return "", false, fmt.Errorf("failed to encode concurrency: %w", err) + } + if err := evaluator.EvaluateYamlNode(&node); err != nil { + return "", false, fmt.Errorf("failed to evaluate concurrency: %w", err) + } + var evaluated model.RawConcurrency + if err := node.Decode(&evaluated); err != nil { + return "", false, fmt.Errorf("failed to unmarshal evaluated concurrency: %w", err) + } + if evaluated.RawExpression != "" { + return evaluated.RawExpression, false, nil + } + return evaluated.Group, evaluated.CancelInProgress == "true", nil +} + +func toGitContext(input map[string]any) *model.GithubContext { + gitContext := &model.GithubContext{ + EventPath: asString(input["event_path"]), + Workflow: asString(input["workflow"]), + RunID: asString(input["run_id"]), + RunNumber: asString(input["run_number"]), + Actor: asString(input["actor"]), + Repository: asString(input["repository"]), + EventName: asString(input["event_name"]), + Sha: asString(input["sha"]), + Ref: asString(input["ref"]), + RefName: asString(input["ref_name"]), + RefType: asString(input["ref_type"]), + HeadRef: asString(input["head_ref"]), + BaseRef: asString(input["base_ref"]), + Token: asString(input["token"]), + Workspace: asString(input["workspace"]), + Action: asString(input["action"]), + ActionPath: asString(input["action_path"]), + ActionRef: asString(input["action_ref"]), + ActionRepository: asString(input["action_repository"]), + Job: asString(input["job"]), + RepositoryOwner: asString(input["repository_owner"]), + RetentionDays: asString(input["retention_days"]), + } + + event, ok := input["event"].(map[string]any) + if ok { + gitContext.Event = event + } + + return gitContext +} + +func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) { + switch rawOn.Kind { + case yaml.ScalarNode: + var val string + err := rawOn.Decode(&val) + if err != nil { + return nil, err + } + return []*Event{ + {Name: val}, + }, nil + case yaml.SequenceNode: + var val []any + err := rawOn.Decode(&val) + if err != nil { + return nil, err + } + res := make([]*Event, 0, len(val)) + for _, v := range val { + switch t := v.(type) { + case string: + res = append(res, &Event{Name: t}) + default: + return nil, fmt.Errorf("invalid type %T", t) + } + } + return res, nil + case yaml.MappingNode: + events, triggers, err := parseMappingNode[yaml.Node](rawOn) + if err != nil { + return nil, err + } + res := make([]*Event, 0, len(events)) + for i, k := range events { + v := triggers[i] + switch v.Kind { + case yaml.ScalarNode: + res = append(res, &Event{ + Name: k, + }) + case yaml.SequenceNode: + var t []any + err := v.Decode(&t) + if err != nil { + return nil, err + } + schedules := make([]map[string]string, len(t)) + if k == "schedule" { + for i, tt := range t { + vv, ok := tt.(map[string]any) + if !ok { + return nil, fmt.Errorf("unknown on type(schedule): %#v", v) + } + schedules[i] = make(map[string]string, len(vv)) + for k, vvv := range vv { + var ok bool + if schedules[i][k], ok = vvv.(string); !ok { + return nil, fmt.Errorf("unknown on type(schedule): %#v", v) + } + } + } + } + + if len(schedules) == 0 { + schedules = nil + } + res = append(res, &Event{ + Name: k, + schedules: schedules, + }) + case yaml.MappingNode: + acts := make(map[string][]string, len(v.Content)/2) + var inputs []WorkflowDispatchInput + expectedKey := true + var act string + for _, content := range v.Content { + if expectedKey { + if content.Kind != yaml.ScalarNode { + return nil, fmt.Errorf("key type not string: %#v", content) + } + act = "" + err := content.Decode(&act) + if err != nil { + return nil, err + } + } else { + switch content.Kind { + case yaml.SequenceNode: + var t []string + err := content.Decode(&t) + if err != nil { + return nil, err + } + acts[act] = t + case yaml.ScalarNode: + var t string + err := content.Decode(&t) + if err != nil { + return nil, err + } + acts[act] = []string{t} + case yaml.MappingNode: + if k != "workflow_dispatch" || act != "inputs" { + return nil, fmt.Errorf("map should only for workflow_dispatch but %s: %#v", act, content) + } + + var key string + for i, vv := range content.Content { + if i%2 == 0 { + if vv.Kind != yaml.ScalarNode { + return nil, fmt.Errorf("key type not string: %#v", vv) + } + key = "" + if err := vv.Decode(&key); err != nil { + return nil, err + } + } else { + if vv.Kind != yaml.MappingNode { + return nil, fmt.Errorf("key type not map(%s): %#v", key, vv) + } + + input := WorkflowDispatchInput{} + if err := vv.Decode(&input); err != nil { + return nil, err + } + input.Name = key + inputs = append(inputs, input) + } + } + default: + return nil, fmt.Errorf("unknown on type: %#v", content) + } + } + expectedKey = !expectedKey + } + if len(inputs) == 0 { + inputs = nil + } + if len(acts) == 0 { + acts = nil + } + res = append(res, &Event{ + Name: k, + acts: acts, + inputs: inputs, + }) + default: + return nil, fmt.Errorf("unknown on type: %v", v.Kind) + } + } + return res, nil + default: + return nil, fmt.Errorf("unknown on type: %v", rawOn.Kind) + } +} + +// parseMappingNode parse a mapping node and preserve order. +func parseMappingNode[T any](node *yaml.Node) ([]string, []T, error) { + if node.Kind != yaml.MappingNode { + return nil, nil, errors.New("input node is not a mapping node") + } + + var scalars []string + var datas []T + expectKey := true + for _, item := range node.Content { + if expectKey { + if item.Kind != yaml.ScalarNode { + return nil, nil, fmt.Errorf("not a valid scalar node: %v", item.Value) + } + scalars = append(scalars, item.Value) + expectKey = false + } else { + var val T + if err := item.Decode(&val); err != nil { + return nil, nil, err + } + datas = append(datas, val) + expectKey = true + } + } + + if len(scalars) != len(datas) { + return nil, nil, fmt.Errorf("invalid definition of on: %v", node.Value) + } + + return scalars, datas, nil +} + +func asString(v any) string { + if v == nil { + return "" + } else if s, ok := v.(string); ok { + return s + } + return "" +} diff --git a/modules/actions/jobparser/model_test.go b/modules/actions/jobparser/model_test.go new file mode 100644 index 00000000000..d0e82041618 --- /dev/null +++ b/modules/actions/jobparser/model_test.go @@ -0,0 +1,373 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package jobparser + +import ( + "strings" + "testing" + + "github.com/nektos/act/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v4" +) + +func TestParseRawOn(t *testing.T) { + kases := []struct { + input string + result []*Event + }{ + { + input: "on: issue_comment", + result: []*Event{ + { + Name: "issue_comment", + }, + }, + }, + { + input: "on:\n push", + result: []*Event{ + { + Name: "push", + }, + }, + }, + + { + input: "on:\n - push\n - pull_request", + result: []*Event{ + { + Name: "push", + }, + { + Name: "pull_request", + }, + }, + }, + { + input: "on:\n push:\n branches:\n - master", + result: []*Event{ + { + Name: "push", + acts: map[string][]string{ + "branches": { + "master", + }, + }, + }, + }, + }, + { + input: "on:\n push:\n branches: main", + result: []*Event{ + { + Name: "push", + acts: map[string][]string{ + "branches": { + "main", + }, + }, + }, + }, + }, + { + input: "on:\n branch_protection_rule:\n types: [created, deleted]", + result: []*Event{ + { + Name: "branch_protection_rule", + acts: map[string][]string{ + "types": { + "created", + "deleted", + }, + }, + }, + }, + }, + { + input: "on:\n project:\n types: [created, deleted]\n milestone:\n types: [opened, deleted]", + result: []*Event{ + { + Name: "project", + acts: map[string][]string{ + "types": { + "created", + "deleted", + }, + }, + }, + { + Name: "milestone", + acts: map[string][]string{ + "types": { + "opened", + "deleted", + }, + }, + }, + }, + }, + { + input: "on:\n pull_request:\n types:\n - opened\n branches:\n - 'releases/**'", + result: []*Event{ + { + Name: "pull_request", + acts: map[string][]string{ + "types": { + "opened", + }, + "branches": { + "releases/**", + }, + }, + }, + }, + }, + { + input: "on:\n push:\n branches:\n - main\n pull_request:\n types:\n - opened\n branches:\n - '**'", + result: []*Event{ + { + Name: "push", + acts: map[string][]string{ + "branches": { + "main", + }, + }, + }, + { + Name: "pull_request", + acts: map[string][]string{ + "types": { + "opened", + }, + "branches": { + "**", + }, + }, + }, + }, + }, + { + input: "on:\n push:\n branches:\n - 'main'\n - 'releases/**'", + result: []*Event{ + { + Name: "push", + acts: map[string][]string{ + "branches": { + "main", + "releases/**", + }, + }, + }, + }, + }, + { + input: "on:\n push:\n tags:\n - v1.**", + result: []*Event{ + { + Name: "push", + acts: map[string][]string{ + "tags": { + "v1.**", + }, + }, + }, + }, + }, + { + input: "on: [pull_request, workflow_dispatch]", + result: []*Event{ + { + Name: "pull_request", + }, + { + Name: "workflow_dispatch", + }, + }, + }, + { + input: "on:\n schedule:\n - cron: '20 6 * * *'", + result: []*Event{ + { + Name: "schedule", + schedules: []map[string]string{ + { + "cron": "20 6 * * *", + }, + }, + }, + }, + }, + { + input: `on: + workflow_dispatch: + inputs: + logLevel: + description: 'Log level' + required: true + default: 'warning' + type: choice + options: + - info + - warning + - debug + tags: + description: 'Test scenario tags' + required: false + type: boolean + environment: + description: 'Environment to run tests against' + type: environment + required: true + push: +`, + result: []*Event{ + { + Name: "workflow_dispatch", + inputs: []WorkflowDispatchInput{ + { + Name: "logLevel", + Description: "Log level", + Required: true, + Default: "warning", + Type: "choice", + Options: []string{"info", "warning", "debug"}, + }, + { + Name: "tags", + Description: "Test scenario tags", + Required: false, + Type: "boolean", + }, + { + Name: "environment", + Description: "Environment to run tests against", + Type: "environment", + Required: true, + }, + }, + }, + { + Name: "push", + }, + }, + }, + } + for _, kase := range kases { + t.Run(kase.input, func(t *testing.T) { + origin, err := model.ReadWorkflow(strings.NewReader(kase.input)) + assert.NoError(t, err) + + events, err := ParseRawOn(&origin.RawOn) + assert.NoError(t, err) + assert.Equal(t, kase.result, events, events) + }) + } +} + +func TestSingleWorkflow_SetJob(t *testing.T) { + t.Run("erase needs", func(t *testing.T) { + content := ReadTestdata(t, "erase_needs.in.yaml") + want := ReadTestdata(t, "erase_needs.out.yaml") + swf, err := Parse(content) + require.NoError(t, err) + builder := &strings.Builder{} + for _, v := range swf { + id, job := v.Job() + require.NoError(t, v.SetJob(id, job.EraseNeeds())) + + if builder.Len() > 0 { + builder.WriteString("---\n") + } + encoder := yaml.NewEncoder(builder) + encoder.SetIndent(2) + require.NoError(t, encoder.Encode(v)) + } + assert.Equal(t, string(want), builder.String()) + }) +} + +func TestParseMappingNode(t *testing.T) { + tests := []struct { + input string + scalars []string + datas []any + }{ + { + input: "on:\n push:\n branches:\n - master", + scalars: []string{"push"}, + datas: []any{ + map[string]any{ + "branches": []any{"master"}, + }, + }, + }, + { + input: "on:\n branch_protection_rule:\n types: [created, deleted]", + scalars: []string{"branch_protection_rule"}, + datas: []any{ + map[string]any{ + "types": []any{"created", "deleted"}, + }, + }, + }, + { + input: "on:\n project:\n types: [created, deleted]\n milestone:\n types: [opened, deleted]", + scalars: []string{"project", "milestone"}, + datas: []any{ + map[string]any{ + "types": []any{"created", "deleted"}, + }, + map[string]any{ + "types": []any{"opened", "deleted"}, + }, + }, + }, + { + input: "on:\n pull_request:\n types:\n - opened\n branches:\n - 'releases/**'", + scalars: []string{"pull_request"}, + datas: []any{ + map[string]any{ + "types": []any{"opened"}, + "branches": []any{"releases/**"}, + }, + }, + }, + { + input: "on:\n push:\n branches:\n - main\n pull_request:\n types:\n - opened\n branches:\n - '**'", + scalars: []string{"push", "pull_request"}, + datas: []any{ + map[string]any{ + "branches": []any{"main"}, + }, + map[string]any{ + "types": []any{"opened"}, + "branches": []any{"**"}, + }, + }, + }, + { + input: "on:\n schedule:\n - cron: '20 6 * * *'", + scalars: []string{"schedule"}, + datas: []any{ + []any{map[string]any{ + "cron": "20 6 * * *", + }}, + }, + }, + } + + for _, test := range tests { + t.Run(test.input, func(t *testing.T) { + workflow, err := model.ReadWorkflow(strings.NewReader(test.input)) + assert.NoError(t, err) + + scalars, datas, err := parseMappingNode[any](&workflow.RawOn) + assert.NoError(t, err) + assert.Equal(t, test.scalars, scalars, scalars) + assert.Equal(t, test.datas, datas, datas) + }) + } +} diff --git a/modules/actions/jobparser/testdata/empty_step.in.yaml b/modules/actions/jobparser/testdata/empty_step.in.yaml new file mode 100644 index 00000000000..737ac0b2c60 --- /dev/null +++ b/modules/actions/jobparser/testdata/empty_step.in.yaml @@ -0,0 +1,8 @@ +name: test +jobs: + job1: + name: job1 + runs-on: linux + steps: + - run: echo job-1 + - diff --git a/modules/actions/jobparser/testdata/empty_step.out.yaml b/modules/actions/jobparser/testdata/empty_step.out.yaml new file mode 100644 index 00000000000..06828e0ed72 --- /dev/null +++ b/modules/actions/jobparser/testdata/empty_step.out.yaml @@ -0,0 +1,7 @@ +name: test +jobs: + job1: + name: job1 + runs-on: linux + steps: + - run: echo job-1 diff --git a/modules/actions/jobparser/testdata/erase_needs.in.yaml b/modules/actions/jobparser/testdata/erase_needs.in.yaml new file mode 100644 index 00000000000..a7d1f9b631d --- /dev/null +++ b/modules/actions/jobparser/testdata/erase_needs.in.yaml @@ -0,0 +1,16 @@ +name: test +jobs: + job1: + runs-on: linux + steps: + - run: uname -a + job2: + runs-on: linux + steps: + - run: uname -a + needs: job1 + job3: + runs-on: linux + steps: + - run: uname -a + needs: [job1, job2] diff --git a/modules/actions/jobparser/testdata/erase_needs.out.yaml b/modules/actions/jobparser/testdata/erase_needs.out.yaml new file mode 100644 index 00000000000..959960d62d9 --- /dev/null +++ b/modules/actions/jobparser/testdata/erase_needs.out.yaml @@ -0,0 +1,23 @@ +name: test +jobs: + job1: + name: job1 + runs-on: linux + steps: + - run: uname -a +--- +name: test +jobs: + job2: + name: job2 + runs-on: linux + steps: + - run: uname -a +--- +name: test +jobs: + job3: + name: job3 + runs-on: linux + steps: + - run: uname -a diff --git a/modules/actions/jobparser/testdata/has_needs.in.yaml b/modules/actions/jobparser/testdata/has_needs.in.yaml new file mode 100644 index 00000000000..a7d1f9b631d --- /dev/null +++ b/modules/actions/jobparser/testdata/has_needs.in.yaml @@ -0,0 +1,16 @@ +name: test +jobs: + job1: + runs-on: linux + steps: + - run: uname -a + job2: + runs-on: linux + steps: + - run: uname -a + needs: job1 + job3: + runs-on: linux + steps: + - run: uname -a + needs: [job1, job2] diff --git a/modules/actions/jobparser/testdata/has_needs.out.yaml b/modules/actions/jobparser/testdata/has_needs.out.yaml new file mode 100644 index 00000000000..a544aa2223b --- /dev/null +++ b/modules/actions/jobparser/testdata/has_needs.out.yaml @@ -0,0 +1,25 @@ +name: test +jobs: + job1: + name: job1 + runs-on: linux + steps: + - run: uname -a +--- +name: test +jobs: + job2: + name: job2 + needs: job1 + runs-on: linux + steps: + - run: uname -a +--- +name: test +jobs: + job3: + name: job3 + needs: [job1, job2] + runs-on: linux + steps: + - run: uname -a diff --git a/modules/actions/jobparser/testdata/has_secrets.in.yaml b/modules/actions/jobparser/testdata/has_secrets.in.yaml new file mode 100644 index 00000000000..64b9f69f085 --- /dev/null +++ b/modules/actions/jobparser/testdata/has_secrets.in.yaml @@ -0,0 +1,14 @@ +name: test +jobs: + job1: + name: job1 + runs-on: linux + uses: .gitea/workflows/build.yml + secrets: + secret: hideme + + job2: + name: job2 + runs-on: linux + uses: .gitea/workflows/build.yml + secrets: inherit diff --git a/modules/actions/jobparser/testdata/has_secrets.out.yaml b/modules/actions/jobparser/testdata/has_secrets.out.yaml new file mode 100644 index 00000000000..23dfb80367b --- /dev/null +++ b/modules/actions/jobparser/testdata/has_secrets.out.yaml @@ -0,0 +1,16 @@ +name: test +jobs: + job1: + name: job1 + runs-on: linux + uses: .gitea/workflows/build.yml + secrets: + secret: hideme +--- +name: test +jobs: + job2: + name: job2 + runs-on: linux + uses: .gitea/workflows/build.yml + secrets: inherit diff --git a/modules/actions/jobparser/testdata/has_with.in.yaml b/modules/actions/jobparser/testdata/has_with.in.yaml new file mode 100644 index 00000000000..4e3dc7451cc --- /dev/null +++ b/modules/actions/jobparser/testdata/has_with.in.yaml @@ -0,0 +1,15 @@ +name: test +jobs: + job1: + name: job1 + runs-on: linux + uses: .gitea/workflows/build.yml + with: + package: service + + job2: + name: job2 + runs-on: linux + uses: .gitea/workflows/build.yml + with: + package: module diff --git a/modules/actions/jobparser/testdata/has_with.out.yaml b/modules/actions/jobparser/testdata/has_with.out.yaml new file mode 100644 index 00000000000..de79b803126 --- /dev/null +++ b/modules/actions/jobparser/testdata/has_with.out.yaml @@ -0,0 +1,17 @@ +name: test +jobs: + job1: + name: job1 + runs-on: linux + uses: .gitea/workflows/build.yml + with: + package: service +--- +name: test +jobs: + job2: + name: job2 + runs-on: linux + uses: .gitea/workflows/build.yml + with: + package: module diff --git a/modules/actions/jobparser/testdata/job_name_with_matrix.in.yaml b/modules/actions/jobparser/testdata/job_name_with_matrix.in.yaml new file mode 100644 index 00000000000..72c700da054 --- /dev/null +++ b/modules/actions/jobparser/testdata/job_name_with_matrix.in.yaml @@ -0,0 +1,14 @@ +name: test +jobs: + job1: + strategy: + matrix: + os: [ubuntu-22.04, ubuntu-20.04] + version: [1.17, 1.18, 1.19] + runs-on: ${{ matrix.os }} + name: test_version_${{ matrix.version }}_on_${{ matrix.os }} + steps: + - uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.version }} + - run: uname -a && go version diff --git a/modules/actions/jobparser/testdata/job_name_with_matrix.out.yaml b/modules/actions/jobparser/testdata/job_name_with_matrix.out.yaml new file mode 100644 index 00000000000..081e8d4abd6 --- /dev/null +++ b/modules/actions/jobparser/testdata/job_name_with_matrix.out.yaml @@ -0,0 +1,101 @@ +name: test +jobs: + job1: + name: test_version_1.17_on_ubuntu-20.04 + runs-on: ubuntu-20.04 + steps: + - uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.version }} + - run: uname -a && go version + strategy: + matrix: + os: + - ubuntu-20.04 + version: + - 1.17 +--- +name: test +jobs: + job1: + name: test_version_1.18_on_ubuntu-20.04 + runs-on: ubuntu-20.04 + steps: + - uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.version }} + - run: uname -a && go version + strategy: + matrix: + os: + - ubuntu-20.04 + version: + - 1.18 +--- +name: test +jobs: + job1: + name: test_version_1.19_on_ubuntu-20.04 + runs-on: ubuntu-20.04 + steps: + - uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.version }} + - run: uname -a && go version + strategy: + matrix: + os: + - ubuntu-20.04 + version: + - 1.19 +--- +name: test +jobs: + job1: + name: test_version_1.17_on_ubuntu-22.04 + runs-on: ubuntu-22.04 + steps: + - uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.version }} + - run: uname -a && go version + strategy: + matrix: + os: + - ubuntu-22.04 + version: + - 1.17 +--- +name: test +jobs: + job1: + name: test_version_1.18_on_ubuntu-22.04 + runs-on: ubuntu-22.04 + steps: + - uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.version }} + - run: uname -a && go version + strategy: + matrix: + os: + - ubuntu-22.04 + version: + - 1.18 +--- +name: test +jobs: + job1: + name: test_version_1.19_on_ubuntu-22.04 + runs-on: ubuntu-22.04 + steps: + - uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.version }} + - run: uname -a && go version + strategy: + matrix: + os: + - ubuntu-22.04 + version: + - 1.19 diff --git a/modules/actions/jobparser/testdata/multiple_jobs.in.yaml b/modules/actions/jobparser/testdata/multiple_jobs.in.yaml new file mode 100644 index 00000000000..266ede847a2 --- /dev/null +++ b/modules/actions/jobparser/testdata/multiple_jobs.in.yaml @@ -0,0 +1,22 @@ +name: test +jobs: + zzz: + runs-on: linux + steps: + - run: echo zzz + job1: + runs-on: linux + steps: + - run: uname -a && go version + job2: + runs-on: linux + steps: + - run: uname -a && go version + job3: + runs-on: linux + steps: + - run: uname -a && go version + aaa: + runs-on: linux + steps: + - run: uname -a && go version diff --git a/modules/actions/jobparser/testdata/multiple_jobs.out.yaml b/modules/actions/jobparser/testdata/multiple_jobs.out.yaml new file mode 100644 index 00000000000..ea2235001cc --- /dev/null +++ b/modules/actions/jobparser/testdata/multiple_jobs.out.yaml @@ -0,0 +1,39 @@ +name: test +jobs: + zzz: + name: zzz + runs-on: linux + steps: + - run: echo zzz +--- +name: test +jobs: + job1: + name: job1 + runs-on: linux + steps: + - run: uname -a && go version +--- +name: test +jobs: + job2: + name: job2 + runs-on: linux + steps: + - run: uname -a && go version +--- +name: test +jobs: + job3: + name: job3 + runs-on: linux + steps: + - run: uname -a && go version +--- +name: test +jobs: + aaa: + name: aaa + runs-on: linux + steps: + - run: uname -a && go version diff --git a/modules/actions/jobparser/testdata/multiple_matrix.in.yaml b/modules/actions/jobparser/testdata/multiple_matrix.in.yaml new file mode 100644 index 00000000000..8b9360c3272 --- /dev/null +++ b/modules/actions/jobparser/testdata/multiple_matrix.in.yaml @@ -0,0 +1,13 @@ +name: test +jobs: + job1: + strategy: + matrix: + os: [ubuntu-22.04, ubuntu-20.04] + version: [1.17, 1.18, 1.19] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.version }} + - run: uname -a && go version diff --git a/modules/actions/jobparser/testdata/multiple_matrix.out.yaml b/modules/actions/jobparser/testdata/multiple_matrix.out.yaml new file mode 100644 index 00000000000..e277cdd0eeb --- /dev/null +++ b/modules/actions/jobparser/testdata/multiple_matrix.out.yaml @@ -0,0 +1,101 @@ +name: test +jobs: + job1: + name: job1 (ubuntu-20.04, 1.17) + runs-on: ubuntu-20.04 + steps: + - uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.version }} + - run: uname -a && go version + strategy: + matrix: + os: + - ubuntu-20.04 + version: + - 1.17 +--- +name: test +jobs: + job1: + name: job1 (ubuntu-20.04, 1.18) + runs-on: ubuntu-20.04 + steps: + - uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.version }} + - run: uname -a && go version + strategy: + matrix: + os: + - ubuntu-20.04 + version: + - 1.18 +--- +name: test +jobs: + job1: + name: job1 (ubuntu-20.04, 1.19) + runs-on: ubuntu-20.04 + steps: + - uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.version }} + - run: uname -a && go version + strategy: + matrix: + os: + - ubuntu-20.04 + version: + - 1.19 +--- +name: test +jobs: + job1: + name: job1 (ubuntu-22.04, 1.17) + runs-on: ubuntu-22.04 + steps: + - uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.version }} + - run: uname -a && go version + strategy: + matrix: + os: + - ubuntu-22.04 + version: + - 1.17 +--- +name: test +jobs: + job1: + name: job1 (ubuntu-22.04, 1.18) + runs-on: ubuntu-22.04 + steps: + - uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.version }} + - run: uname -a && go version + strategy: + matrix: + os: + - ubuntu-22.04 + version: + - 1.18 +--- +name: test +jobs: + job1: + name: job1 (ubuntu-22.04, 1.19) + runs-on: ubuntu-22.04 + steps: + - uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.version }} + - run: uname -a && go version + strategy: + matrix: + os: + - ubuntu-22.04 + version: + - 1.19 diff --git a/modules/actions/jobparser/testdata/prefixed_newline.in.yaml b/modules/actions/jobparser/testdata/prefixed_newline.in.yaml new file mode 100644 index 00000000000..91f8530288f --- /dev/null +++ b/modules/actions/jobparser/testdata/prefixed_newline.in.yaml @@ -0,0 +1,14 @@ +name: Step with leading new line + +on: + push: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: "\nExtract tag for variant" + id: extract_tag + run: | + + echo Test diff --git a/modules/actions/jobparser/testdata/prefixed_newline.out.yaml b/modules/actions/jobparser/testdata/prefixed_newline.out.yaml new file mode 100644 index 00000000000..027e9dfa1a2 --- /dev/null +++ b/modules/actions/jobparser/testdata/prefixed_newline.out.yaml @@ -0,0 +1,15 @@ +name: Step with leading new line +"on": + push: +jobs: + test: + name: test + runs-on: ubuntu-latest + steps: + - id: extract_tag + name: |2- + + Extract tag for variant + run: |2 + + echo Test diff --git a/modules/actions/jobparser/testdata_test.go b/modules/actions/jobparser/testdata_test.go new file mode 100644 index 00000000000..872484e25e6 --- /dev/null +++ b/modules/actions/jobparser/testdata_test.go @@ -0,0 +1,21 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package jobparser + +import ( + "embed" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +//go:embed testdata +var testdata embed.FS + +func ReadTestdata(t *testing.T, name string) []byte { + content, err := testdata.ReadFile(filepath.Join("testdata", name)) + require.NoError(t, err) + return content +} diff --git a/modules/actions/log.go b/modules/actions/log.go index 5a1425e0317..3fb56b402ab 100644 --- a/modules/actions/log.go +++ b/modules/actions/log.go @@ -33,21 +33,22 @@ const ( // It doesn't respect the file format in the filename like ".zst", since it's difficult to reopen a closed compressed file and append new content. // Why doesn't it store logs in object storage directly? Because it's not efficient to append content to object storage. func WriteLogs(ctx context.Context, filename string, offset int64, rows []*runnerv1.LogRow) ([]int, error) { - flag := os.O_WRONLY + flag, openFileFor := os.O_WRONLY, "write-only" if offset == 0 { - // Create file only if offset is 0, or it could result in content holes if the file doesn't exist. - flag |= os.O_CREATE + // Only allow to create file if offset is 0 (the first write), see #25560. + // Otherwise, it might result in content holes if the file has been deleted after transferred (actions.TransferLogs). + flag, openFileFor = os.O_WRONLY|os.O_CREATE, "write-create" } name := DBFSPrefix + filename f, err := dbfs.OpenFile(ctx, name, flag) if err != nil { - return nil, fmt.Errorf("dbfs OpenFile %q: %w", name, err) + return nil, fmt.Errorf("dbfs.OpenFile %q for %s: %w", name, openFileFor, err) } defer f.Close() stat, err := f.Stat() if err != nil { - return nil, fmt.Errorf("dbfs Stat %q: %w", name, err) + return nil, fmt.Errorf("dbfs.Stat %q: %w", name, err) } if stat.Size() < offset { // If the size is less than offset, refuse to write, or it could result in content holes. @@ -56,7 +57,7 @@ func WriteLogs(ctx context.Context, filename string, offset int64, rows []*runne } if _, err := f.Seek(offset, io.SeekStart); err != nil { - return nil, fmt.Errorf("dbfs Seek %q: %w", name, err) + return nil, fmt.Errorf("dbfs.Seek %q: %w", name, err) } writer := bufio.NewWriterSize(f, defaultBufSize) @@ -121,16 +122,17 @@ const ( // TransferLogs transfers logs from DBFS to object storage. // It happens when the file is complete and no more logs will be appended. // It respects the file format in the filename like ".zst", and compresses the content if needed. +// The task log file must be marked as "log_in_storage=true" after the transfer. func TransferLogs(ctx context.Context, filename string) (func(), error) { name := DBFSPrefix + filename remove := func() { if err := dbfs.Remove(ctx, name); err != nil { - log.Warn("dbfs remove %q: %v", name, err) + log.Warn("dbfs.Remove %q: %v", name, err) } } f, err := dbfs.Open(ctx, name) if err != nil { - return nil, fmt.Errorf("dbfs open %q: %w", name, err) + return nil, fmt.Errorf("dbfs.Open %q: %w", name, err) } defer f.Close() @@ -164,7 +166,7 @@ func RemoveLogs(ctx context.Context, inStorage bool, filename string) error { name := DBFSPrefix + filename err := dbfs.Remove(ctx, name) if err != nil { - return fmt.Errorf("dbfs remove %q: %w", name, err) + return fmt.Errorf("dbfs.Remove %q: %w", name, err) } return nil } @@ -180,7 +182,7 @@ func OpenLogs(ctx context.Context, inStorage bool, filename string) (io.ReadSeek name := DBFSPrefix + filename f, err := dbfs.Open(ctx, name) if err != nil { - return nil, fmt.Errorf("dbfs open %q: %w", name, err) + return nil, fmt.Errorf("dbfs.Open %q: %w", name, err) } return f, nil } diff --git a/modules/actions/workflows.go b/modules/actions/workflows.go index 69f71bf6519..4ac06def4d5 100644 --- a/modules/actions/workflows.go +++ b/modules/actions/workflows.go @@ -5,20 +5,21 @@ package actions import ( "bytes" - "io" "slices" "strings" + "code.gitea.io/gitea/modules/actions/jobparser" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/glob" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" webhook_module "code.gitea.io/gitea/modules/webhook" - "github.com/nektos/act/pkg/jobparser" "github.com/nektos/act/pkg/model" "github.com/nektos/act/pkg/workflowpattern" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v4" ) type DetectedWorkflow struct { @@ -41,22 +42,30 @@ func IsWorkflow(path string) bool { return false } - return strings.HasPrefix(path, ".gitea/workflows") || strings.HasPrefix(path, ".github/workflows") + for _, workflowDir := range setting.Actions.WorkflowDirs { + if strings.HasPrefix(path, workflowDir+"/") { + return true + } + } + return false } func ListWorkflows(commit *git.Commit) (string, git.Entries, error) { - rpath := ".gitea/workflows" - tree, err := commit.SubTree(rpath) - if _, ok := err.(git.ErrNotExist); ok { - rpath = ".github/workflows" - tree, err = commit.SubTree(rpath) + var tree *git.Tree + var err error + var workflowDir string + for _, workflowDir = range setting.Actions.WorkflowDirs { + tree, err = commit.SubTree(workflowDir) + if err == nil { + break + } + if !git.IsErrNotExist(err) { + return "", nil, err + } } - if _, ok := err.(git.ErrNotExist); ok { + if tree == nil { return "", nil, nil } - if err != nil { - return "", nil, err - } entries, err := tree.ListEntriesRecursiveFast() if err != nil { @@ -69,7 +78,7 @@ func ListWorkflows(commit *git.Commit) (string, git.Entries, error) { ret = append(ret, entry) } } - return rpath, ret, nil + return workflowDir, ret, nil } func GetContentFromEntry(entry *git.TreeEntry) ([]byte, error) { @@ -77,7 +86,7 @@ func GetContentFromEntry(entry *git.TreeEntry) ([]byte, error) { if err != nil { return nil, err } - content, err := io.ReadAll(f) + content, err := util.ReadWithLimit(f, 1024*1024) _ = f.Close() if err != nil { return nil, err diff --git a/modules/actions/workflows_test.go b/modules/actions/workflows_test.go index 89620fb6988..ea027366f7e 100644 --- a/modules/actions/workflows_test.go +++ b/modules/actions/workflows_test.go @@ -7,12 +7,83 @@ import ( "testing" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" webhook_module "code.gitea.io/gitea/modules/webhook" "github.com/stretchr/testify/assert" ) +func TestIsWorkflow(t *testing.T) { + oldDirs := setting.Actions.WorkflowDirs + defer func() { + setting.Actions.WorkflowDirs = oldDirs + }() + + tests := []struct { + name string + dirs []string + path string + expected bool + }{ + { + name: "default with yml extension", + dirs: []string{".gitea/workflows", ".github/workflows"}, + path: ".gitea/workflows/test.yml", + expected: true, + }, + { + name: "default with yaml extension", + dirs: []string{".gitea/workflows", ".github/workflows"}, + path: ".github/workflows/test.yaml", + expected: true, + }, + { + name: "only gitea configured, github path rejected", + dirs: []string{".gitea/workflows"}, + path: ".github/workflows/test.yml", + expected: false, + }, + { + name: "only github configured, gitea path rejected", + dirs: []string{".github/workflows"}, + path: ".gitea/workflows/test.yml", + expected: false, + }, + { + name: "custom workflow dir", + dirs: []string{".custom/workflows"}, + path: ".custom/workflows/deploy.yml", + expected: true, + }, + { + name: "non-workflow file", + dirs: []string{".gitea/workflows", ".github/workflows"}, + path: ".gitea/workflows/readme.md", + expected: false, + }, + { + name: "directory boundary", + dirs: []string{".gitea/workflows"}, + path: ".gitea/workflows2/test.yml", + expected: false, + }, + { + name: "unrelated path", + dirs: []string{".gitea/workflows", ".github/workflows"}, + path: "src/main.go", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setting.Actions.WorkflowDirs = tt.dirs + assert.Equal(t, tt.expected, IsWorkflow(tt.path)) + }) + } +} + func TestDetectMatched(t *testing.T) { testCases := []struct { desc string @@ -119,7 +190,7 @@ func TestDetectMatched(t *testing.T) { expected: true, }, { - desc: "HookEventSchedue(schedule) matches GithubEventSchedule(schedule)", + desc: "HookEventSchedule(schedule) matches GithubEventSchedule(schedule)", triggedEvent: webhook_module.HookEventSchedule, payload: nil, yamlOn: "on: schedule", diff --git a/modules/activitypub/client.go b/modules/activitypub/client.go deleted file mode 100644 index a9941f9b759..00000000000 --- a/modules/activitypub/client.go +++ /dev/null @@ -1,124 +0,0 @@ -// Copyright 2022 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package activitypub - -import ( - "bytes" - "context" - "crypto/rsa" - "crypto/x509" - "encoding/pem" - "fmt" - "net/http" - "strings" - "time" - - user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/proxy" - "code.gitea.io/gitea/modules/setting" - - "github.com/42wim/httpsig" -) - -const ( - // ActivityStreamsContentType const - ActivityStreamsContentType = `application/ld+json; profile="https://www.w3.org/ns/activitystreams"` - httpsigExpirationTime = 60 -) - -// Gets the current time as an RFC 2616 formatted string -// RFC 2616 requires RFC 1123 dates but with GMT instead of UTC -func CurrentTime() string { - return strings.ReplaceAll(time.Now().UTC().Format(time.RFC1123), "UTC", "GMT") -} - -func containsRequiredHTTPHeaders(method string, headers []string) error { - var hasRequestTarget, hasDate, hasDigest bool - for _, header := range headers { - hasRequestTarget = hasRequestTarget || header == httpsig.RequestTarget - hasDate = hasDate || header == "Date" - hasDigest = hasDigest || header == "Digest" - } - if !hasRequestTarget { - return fmt.Errorf("missing http header for %s: %s", method, httpsig.RequestTarget) - } else if !hasDate { - return fmt.Errorf("missing http header for %s: Date", method) - } else if !hasDigest && method != http.MethodGet { - return fmt.Errorf("missing http header for %s: Digest", method) - } - return nil -} - -// Client struct -type Client struct { - client *http.Client - algs []httpsig.Algorithm - digestAlg httpsig.DigestAlgorithm - getHeaders []string - postHeaders []string - priv *rsa.PrivateKey - pubID string -} - -// NewClient function -func NewClient(ctx context.Context, user *user_model.User, pubID string) (c *Client, err error) { - if err = containsRequiredHTTPHeaders(http.MethodGet, setting.Federation.GetHeaders); err != nil { - return nil, err - } else if err = containsRequiredHTTPHeaders(http.MethodPost, setting.Federation.PostHeaders); err != nil { - return nil, err - } - - priv, err := GetPrivateKey(ctx, user) - if err != nil { - return nil, err - } - privPem, _ := pem.Decode([]byte(priv)) - privParsed, err := x509.ParsePKCS1PrivateKey(privPem.Bytes) - if err != nil { - return nil, err - } - - c = &Client{ - client: &http.Client{ - Transport: &http.Transport{ - Proxy: proxy.Proxy(), - }, - }, - algs: setting.HttpsigAlgs, - digestAlg: httpsig.DigestAlgorithm(setting.Federation.DigestAlgorithm), - getHeaders: setting.Federation.GetHeaders, - postHeaders: setting.Federation.PostHeaders, - priv: privParsed, - pubID: pubID, - } - return c, err -} - -// NewRequest function -func (c *Client) NewRequest(b []byte, to string) (req *http.Request, err error) { - buf := bytes.NewBuffer(b) - req, err = http.NewRequest(http.MethodPost, to, buf) - if err != nil { - return nil, err - } - req.Header.Add("Content-Type", ActivityStreamsContentType) - req.Header.Add("Date", CurrentTime()) - req.Header.Add("User-Agent", "Gitea/"+setting.AppVer) - signer, _, err := httpsig.NewSigner(c.algs, c.digestAlg, c.postHeaders, httpsig.Signature, httpsigExpirationTime) - if err != nil { - return nil, err - } - err = signer.SignRequest(c.priv, c.pubID, req, b) - return req, err -} - -// Post function -func (c *Client) Post(b []byte, to string) (resp *http.Response, err error) { - var req *http.Request - if req, err = c.NewRequest(b, to); err != nil { - return nil, err - } - resp, err = c.client.Do(req) - return resp, err -} diff --git a/modules/activitypub/client_test.go b/modules/activitypub/client_test.go deleted file mode 100644 index 361270a8005..00000000000 --- a/modules/activitypub/client_test.go +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright 2022 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package activitypub - -import ( - "fmt" - "io" - "net/http" - "net/http/httptest" - "testing" - - "code.gitea.io/gitea/models/unittest" - user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/setting" - - "github.com/stretchr/testify/assert" -) - -func TestActivityPubSignedPost(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) - user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) - pubID := "https://example.com/pubID" - c, err := NewClient(t.Context(), user, pubID) - assert.NoError(t, err) - - expected := "BODY" - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Regexp(t, "^"+setting.Federation.DigestAlgorithm, r.Header.Get("Digest")) - assert.Contains(t, r.Header.Get("Signature"), pubID) - assert.Equal(t, ActivityStreamsContentType, r.Header.Get("Content-Type")) - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - assert.Equal(t, expected, string(body)) - fmt.Fprint(w, expected) - })) - defer srv.Close() - - r, err := c.Post([]byte(expected), srv.URL) - assert.NoError(t, err) - defer r.Body.Close() - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - assert.Equal(t, expected, string(body)) -} diff --git a/modules/activitypub/main_test.go b/modules/activitypub/main_test.go deleted file mode 100644 index 4591f1fa555..00000000000 --- a/modules/activitypub/main_test.go +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright 2022 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package activitypub - -import ( - "testing" - - "code.gitea.io/gitea/models/unittest" - - _ "code.gitea.io/gitea/models" - _ "code.gitea.io/gitea/models/actions" - _ "code.gitea.io/gitea/models/activities" -) - -func TestMain(m *testing.M) { - unittest.MainTest(m) -} diff --git a/modules/activitypub/user_settings.go b/modules/activitypub/user_settings.go deleted file mode 100644 index 7f939af352a..00000000000 --- a/modules/activitypub/user_settings.go +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright 2022 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package activitypub - -import ( - "context" - - user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/util" -) - -const rsaBits = 3072 - -// GetKeyPair function returns a user's private and public keys -func GetKeyPair(ctx context.Context, user *user_model.User) (pub, priv string, err error) { - var settings map[string]*user_model.Setting - settings, err = user_model.GetSettings(ctx, user.ID, []string{user_model.UserActivityPubPrivPem, user_model.UserActivityPubPubPem}) - if err != nil { - return pub, priv, err - } else if len(settings) == 0 { - if priv, pub, err = util.GenerateKeyPair(rsaBits); err != nil { - return pub, priv, err - } - if err = user_model.SetUserSetting(ctx, user.ID, user_model.UserActivityPubPrivPem, priv); err != nil { - return pub, priv, err - } - if err = user_model.SetUserSetting(ctx, user.ID, user_model.UserActivityPubPubPem, pub); err != nil { - return pub, priv, err - } - return pub, priv, err - } - priv = settings[user_model.UserActivityPubPrivPem].SettingValue - pub = settings[user_model.UserActivityPubPubPem].SettingValue - return pub, priv, err -} - -// GetPublicKey function returns a user's public key -func GetPublicKey(ctx context.Context, user *user_model.User) (pub string, err error) { - pub, _, err = GetKeyPair(ctx, user) - return pub, err -} - -// GetPrivateKey function returns a user's private key -func GetPrivateKey(ctx context.Context, user *user_model.User) (priv string, err error) { - _, priv, err = GetKeyPair(ctx, user) - return priv, err -} diff --git a/modules/activitypub/user_settings_test.go b/modules/activitypub/user_settings_test.go deleted file mode 100644 index 105d4aedea1..00000000000 --- a/modules/activitypub/user_settings_test.go +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright 2022 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package activitypub - -import ( - "testing" - - "code.gitea.io/gitea/models/unittest" - user_model "code.gitea.io/gitea/models/user" - - _ "code.gitea.io/gitea/models" // https://forum.gitea.com/t/testfixtures-could-not-clean-table-access-no-such-table-access/4137/4 - - "github.com/stretchr/testify/assert" -) - -func TestUserSettings(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) - user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) - pub, priv, err := GetKeyPair(t.Context(), user1) - assert.NoError(t, err) - pub1, err := GetPublicKey(t.Context(), user1) - assert.NoError(t, err) - assert.Equal(t, pub, pub1) - priv1, err := GetPrivateKey(t.Context(), user1) - assert.NoError(t, err) - assert.Equal(t, priv, priv1) -} diff --git a/modules/analyze/code_language.go b/modules/analyze/code_language.go index 74e7a06d061..d8589861d39 100644 --- a/modules/analyze/code_language.go +++ b/modules/analyze/code_language.go @@ -4,12 +4,13 @@ package analyze import ( - "path/filepath" + "path" "github.com/go-enry/go-enry/v2" ) // GetCodeLanguage detects code language based on file name and content +// It can be slow when the content is used for detection func GetCodeLanguage(filename string, content []byte) string { if language, ok := enry.GetLanguageByExtension(filename); ok { return language @@ -23,5 +24,5 @@ func GetCodeLanguage(filename string, content []byte) string { return enry.OtherLanguage } - return enry.GetLanguage(filepath.Base(filename), content) + return enry.GetLanguage(path.Base(filename), content) } diff --git a/modules/analyze/vendor.go b/modules/analyze/vendor.go index adcca923dd6..f3e75f535f4 100644 --- a/modules/analyze/vendor.go +++ b/modules/analyze/vendor.go @@ -4,10 +4,28 @@ package analyze import ( + "path" + "strings" + "github.com/go-enry/go-enry/v2" ) -// IsVendor returns whether or not path is a vendor path. -func IsVendor(path string) bool { - return enry.IsVendor(path) +// IsVendor returns whether the path is a vendor path. +// It uses go-enry's IsVendor function but overrides its detection for certain +// special cases that shouldn't be marked as vendored in the diff view. +func IsVendor(treePath string) bool { + if !enry.IsVendor(treePath) { + return false + } + + // Override detection for single files + basename := path.Base(treePath) + switch basename { + case ".gitignore", ".gitattributes", ".gitmodules": + return false + } + if strings.HasPrefix(treePath, ".github/") || strings.HasPrefix(treePath, ".gitea/") { + return false + } + return true } diff --git a/modules/analyze/vendor_test.go b/modules/analyze/vendor_test.go index 02a51d4c8fd..6efb825de60 100644 --- a/modules/analyze/vendor_test.go +++ b/modules/analyze/vendor_test.go @@ -14,6 +14,7 @@ func TestIsVendor(t *testing.T) { path string want bool }{ + // Original go-enry test cases {"cache/", true}, {"random/cache/", true}, {"cache", false}, @@ -34,6 +35,14 @@ func TestIsVendor(t *testing.T) { {"a/docs/_build/", true}, {"a/dasdocs/_build-vsdoc.js", true}, {"a/dasdocs/_build-vsdoc.j", false}, + + // Override: Git/GitHub/Gitea-related paths should NOT be detected as vendored + {".gitignore", false}, + {".gitattributes", false}, + {".gitmodules", false}, + {"src/.gitignore", false}, + {".github/workflows/ci.yml", false}, + {".gitea/workflows/ci.yml", false}, } for _, tt := range tests { t.Run(tt.path, func(t *testing.T) { diff --git a/modules/assetfs/embed.go b/modules/assetfs/embed.go index 95176372d10..7c90e3aca21 100644 --- a/modules/assetfs/embed.go +++ b/modules/assetfs/embed.go @@ -260,7 +260,7 @@ func (fi *embeddedFileInfo) Mode() fs.FileMode { } func (fi *embeddedFileInfo) ModTime() time.Time { - return getExecutableModTime() + return GetExecutableModTime() } func (fi *embeddedFileInfo) IsDir() bool { @@ -279,9 +279,9 @@ func (fi *embeddedFileInfo) Info() (fs.FileInfo, error) { return fi, nil } -// getExecutableModTime returns the modification time of the executable file. +// GetExecutableModTime returns the modification time of the executable file. // In bindata, we can't use the ModTime of the files because we need to make the build reproducible -var getExecutableModTime = sync.OnceValue(func() (modTime time.Time) { +var GetExecutableModTime = sync.OnceValue(func() (modTime time.Time) { exePath, err := os.Executable() if err != nil { return modTime @@ -365,11 +365,11 @@ func GenerateEmbedBindata(fsRootPath, outputFile string) error { if err = embedFiles(meta.Root, fsRootPath, ""); err != nil { return err } - jsonBuf, err := json.Marshal(meta) // can't use json.NewEncoder here because it writes extra EOL + jsonBuf, err := json.Marshal(meta) if err != nil { return err } _, _ = output.Write([]byte{'\n'}) - _, err = output.Write(jsonBuf) + _, err = output.Write(bytes.TrimSpace(jsonBuf)) return err } diff --git a/modules/assetfs/layered.go b/modules/assetfs/layered.go index ce55475bd98..41e4ca7376d 100644 --- a/modules/assetfs/layered.go +++ b/modules/assetfs/layered.go @@ -6,9 +6,7 @@ package assetfs import ( "context" "fmt" - "io" "io/fs" - "net/http" "os" "path/filepath" "sort" @@ -25,7 +23,7 @@ import ( // Layer represents a layer in a layered asset file-system. It has a name and works like http.FileSystem type Layer struct { name string - fs http.FileSystem + fs fs.FS localPath string } @@ -34,7 +32,7 @@ func (l *Layer) Name() string { } // Open opens the named file. The caller is responsible for closing the file. -func (l *Layer) Open(name string) (http.File, error) { +func (l *Layer) Open(name string) (fs.File, error) { return l.fs.Open(name) } @@ -48,12 +46,12 @@ func Local(name, base string, sub ...string) *Layer { panic(fmt.Sprintf("Unable to get absolute path for %q: %v", base, err)) } root := util.FilePathJoinAbs(base, sub...) - return &Layer{name: name, fs: http.Dir(root), localPath: root} + return &Layer{name: name, fs: os.DirFS(root), localPath: root} } // Bindata returns a new Layer with the given name, it serves files from the given bindata asset. func Bindata(name string, fs fs.FS) *Layer { - return &Layer{name: name, fs: http.FS(fs)} + return &Layer{name: name, fs: fs} } // LayeredFS is a layered asset file-system. It works like http.FileSystem, but it can have multiple layers. @@ -69,7 +67,7 @@ func Layered(layers ...*Layer) *LayeredFS { } // Open opens the named file. The caller is responsible for closing the file. -func (l *LayeredFS) Open(name string) (http.File, error) { +func (l *LayeredFS) Open(name string) (fs.File, error) { for _, layer := range l.layers { f, err := layer.Open(name) if err == nil || !os.IsNotExist(err) { @@ -89,40 +87,34 @@ func (l *LayeredFS) ReadFile(elems ...string) ([]byte, error) { func (l *LayeredFS) ReadLayeredFile(elems ...string) ([]byte, string, error) { name := util.PathJoinRel(elems...) for _, layer := range l.layers { - f, err := layer.Open(name) + bs, err := fs.ReadFile(layer, name) if os.IsNotExist(err) { continue } else if err != nil { return nil, layer.name, err } - bs, err := io.ReadAll(f) - _ = f.Close() return bs, layer.name, err } return nil, "", fs.ErrNotExist } -func shouldInclude(info fs.FileInfo, fileMode ...bool) bool { - if util.IsCommonHiddenFileName(info.Name()) { +func shouldInclude(dirEntry fs.DirEntry, fileMode ...bool) bool { + if util.IsCommonHiddenFileName(dirEntry.Name()) { return false } if len(fileMode) == 0 { return true } else if len(fileMode) == 1 { - return fileMode[0] == !info.Mode().IsDir() + return fileMode[0] == !dirEntry.IsDir() } panic("too many arguments for fileMode in shouldInclude") } -func readDir(layer *Layer, name string) ([]fs.FileInfo, error) { - f, err := layer.Open(name) - if os.IsNotExist(err) { +func readDirOptional(layer *Layer, name string) (entries []fs.DirEntry, err error) { + if entries, err = fs.ReadDir(layer, name); os.IsNotExist(err) { return nil, nil - } else if err != nil { - return nil, err } - defer f.Close() - return f.Readdir(-1) + return entries, err } // ListFiles lists files/directories in the given directory. The fileMode controls the returned files. @@ -133,13 +125,13 @@ func readDir(layer *Layer, name string) ([]fs.FileInfo, error) { func (l *LayeredFS) ListFiles(name string, fileMode ...bool) ([]string, error) { fileSet := make(container.Set[string]) for _, layer := range l.layers { - infos, err := readDir(layer, name) + entries, err := readDirOptional(layer, name) if err != nil { return nil, err } - for _, info := range infos { - if shouldInclude(info, fileMode...) { - fileSet.Add(info.Name()) + for _, entry := range entries { + if shouldInclude(entry, fileMode...) { + fileSet.Add(entry.Name()) } } } @@ -163,16 +155,16 @@ func listAllFiles(layers []*Layer, name string, fileMode ...bool) ([]string, err var list func(dir string) error list = func(dir string) error { for _, layer := range layers { - infos, err := readDir(layer, dir) + entries, err := readDirOptional(layer, dir) if err != nil { return err } - for _, info := range infos { - path := util.PathJoinRelX(dir, info.Name()) - if shouldInclude(info, fileMode...) { + for _, entry := range entries { + path := util.PathJoinRelX(dir, entry.Name()) + if shouldInclude(entry, fileMode...) { fileSet.Add(path) } - if info.IsDir() { + if entry.IsDir() { if err = list(path); err != nil { return err } diff --git a/modules/auth/password/hash/argon2.go b/modules/auth/password/hash/argon2.go index 0cd6472fa12..f4a7497df69 100644 --- a/modules/auth/password/hash/argon2.go +++ b/modules/auth/password/hash/argon2.go @@ -61,17 +61,11 @@ func NewArgon2Hasher(config string) *Argon2Hasher { return nil } - parsed, err := parseUIntParam(vals[0], "time", "argon2", config, nil) - hasher.time = uint32(parsed) - - parsed, err = parseUIntParam(vals[1], "memory", "argon2", config, err) - hasher.memory = uint32(parsed) - - parsed, err = parseUIntParam(vals[2], "threads", "argon2", config, err) - hasher.threads = uint8(parsed) - - parsed, err = parseUIntParam(vals[3], "keyLen", "argon2", config, err) - hasher.keyLen = uint32(parsed) + var err error + hasher.time, err = parseUintParam[uint32](vals[0], "time", "argon2", config, nil) + hasher.memory, err = parseUintParam[uint32](vals[1], "memory", "argon2", config, err) + hasher.threads, err = parseUintParam[uint8](vals[2], "threads", "argon2", config, err) + hasher.keyLen, err = parseUintParam[uint32](vals[3], "keyLen", "argon2", config, err) if err != nil { return nil } diff --git a/modules/auth/password/hash/common.go b/modules/auth/password/hash/common.go index d5e2c34314e..1fafc289ed4 100644 --- a/modules/auth/password/hash/common.go +++ b/modules/auth/password/hash/common.go @@ -7,6 +7,7 @@ import ( "strconv" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" ) func parseIntParam(value, param, algorithmName, config string, previousErr error) (int, error) { @@ -18,11 +19,12 @@ func parseIntParam(value, param, algorithmName, config string, previousErr error return parsed, previousErr // <- Keep the previous error as this function should still return an error once everything has been checked if any call failed } -func parseUIntParam(value, param, algorithmName, config string, previousErr error) (uint64, error) { //nolint:unparam // algorithmName is always argon2 - parsed, err := strconv.ParseUint(value, 10, 64) +func parseUintParam[T uint32 | uint8](value, param, algorithmName, config string, previousErr error) (ret T, _ error) { + _, isUint32 := any(ret).(uint32) + parsed, err := strconv.ParseUint(value, 10, util.Iif(isUint32, 32, 8)) if err != nil { log.Error("invalid integer for %s representation in %s hash spec %s", param, algorithmName, config) return 0, err } - return parsed, previousErr // <- Keep the previous error as this function should still return an error once everything has been checked if any call failed + return T(parsed), previousErr // <- Keep the previous error as this function should still return an error once everything has been checked if any call failed } diff --git a/modules/auth/password/hash/setting.go b/modules/auth/password/hash/setting.go index f0715f31e1a..e0caed5ba88 100644 --- a/modules/auth/password/hash/setting.go +++ b/modules/auth/password/hash/setting.go @@ -14,7 +14,7 @@ const DefaultHashAlgorithmName = "pbkdf2" var DefaultHashAlgorithm *PasswordHashAlgorithm -// aliasAlgorithNames provides a mapping between the value of PASSWORD_HASH_ALGO +// aliasAlgorithmNames provides a mapping between the value of PASSWORD_HASH_ALGO // configured in the app.ini and the parameters used within the hashers internally. // // If it is necessary to change the default parameters for any hasher in future you diff --git a/modules/auth/password/pwn/pwn.go b/modules/auth/password/pwn/pwn.go index 99a6ca6ceac..d5ea96c4afc 100644 --- a/modules/auth/password/pwn/pwn.go +++ b/modules/auth/password/pwn/pwn.go @@ -72,7 +72,7 @@ func newRequest(ctx context.Context, method, url string, body io.ReadCloser) (*h // Adding padding will make requests more secure, however is also slower // because artificial responses will be added to the response // For more information, see https://www.troyhunt.com/enhancing-pwned-passwords-privacy-with-padding/ -func (c *Client) CheckPassword(pw string, padding bool) (int, error) { +func (c *Client) CheckPassword(pw string, padding bool) (int64, error) { if pw == "" { return -1, ErrEmptyPassword } @@ -111,7 +111,7 @@ func (c *Client) CheckPassword(pw string, padding bool) (int, error) { if err != nil { return -1, err } - return int(count), nil + return count, nil } } return 0, nil diff --git a/modules/auth/password/pwn/pwn_test.go b/modules/auth/password/pwn/pwn_test.go index ae03fabc579..4b760fdf323 100644 --- a/modules/auth/password/pwn/pwn_test.go +++ b/modules/auth/password/pwn/pwn_test.go @@ -37,25 +37,25 @@ func TestPassword(t *testing.T) { count, err := client.CheckPassword("", false) assert.ErrorIs(t, err, ErrEmptyPassword, "blank input should return ErrEmptyPassword") - assert.Equal(t, -1, count) + assert.EqualValues(t, -1, count) count, err = client.CheckPassword("pwned", false) assert.NoError(t, err) - assert.Equal(t, 1, count) + assert.EqualValues(t, 1, count) count, err = client.CheckPassword("notpwned", false) assert.NoError(t, err) - assert.Equal(t, 0, count) + assert.EqualValues(t, 0, count) count, err = client.CheckPassword("paddedpwned", true) assert.NoError(t, err) - assert.Equal(t, 1, count) + assert.EqualValues(t, 1, count) count, err = client.CheckPassword("paddednotpwned", true) assert.NoError(t, err) - assert.Equal(t, 0, count) + assert.EqualValues(t, 0, count) count, err = client.CheckPassword("paddednotpwnedzero", true) assert.NoError(t, err) - assert.Equal(t, 0, count) + assert.EqualValues(t, 0, count) } diff --git a/modules/auth/webauthn/webauthn.go b/modules/auth/webauthn/webauthn.go index cbf5279c651..86f55c6b247 100644 --- a/modules/auth/webauthn/webauthn.go +++ b/modules/auth/webauthn/webauthn.go @@ -22,7 +22,7 @@ var WebAuthn *webauthn.WebAuthn // Init initializes the WebAuthn instance from the config. func Init() { - gob.Register(&webauthn.SessionData{}) + gob.Register(&webauthn.SessionData{}) // TODO: CHI-SESSION-GOB-REGISTER. appURL, _ := protocol.FullyQualifiedOrigin(setting.AppURL) diff --git a/modules/avatar/avatar.go b/modules/avatar/avatar.go index 106215ec0b2..3b622b99af0 100644 --- a/modules/avatar/avatar.go +++ b/modules/avatar/avatar.go @@ -28,21 +28,18 @@ import ( // than the size after resizing. const DefaultAvatarSize = 256 -// RandomImageSize generates and returns a random avatar image unique to input data +// RandomImageWithSize generates and returns a random avatar image unique to input data // in custom size (height and width). -func RandomImageSize(size int, data []byte) (image.Image, error) { +func RandomImageWithSize(size int, data []byte) image.Image { // we use white as background, and use dark colors to draw blocks - imgMaker, err := identicon.New(size, color.White, identicon.DarkColors...) - if err != nil { - return nil, fmt.Errorf("identicon.New: %w", err) - } - return imgMaker.Make(data), nil + imgMaker := identicon.New(size, color.White, identicon.DarkColors) + return imgMaker.Make(data) } -// RandomImage generates and returns a random avatar image unique to input data +// RandomImageDefaultSize generates and returns a random avatar image unique to input data // in default size (height and width). -func RandomImage(data []byte) (image.Image, error) { - return RandomImageSize(DefaultAvatarSize*setting.Avatar.RenderedSizeFactor, data) +func RandomImageDefaultSize(data []byte) image.Image { + return RandomImageWithSize(DefaultAvatarSize*setting.Avatar.RenderedSizeFactor, data) } // processAvatarImage process the avatar image data, crop and resize it if necessary. diff --git a/modules/avatar/avatar_test.go b/modules/avatar/avatar_test.go index a5a1a7c1b06..5414184b6b2 100644 --- a/modules/avatar/avatar_test.go +++ b/modules/avatar/avatar_test.go @@ -15,19 +15,6 @@ import ( "github.com/stretchr/testify/assert" ) -func Test_RandomImageSize(t *testing.T) { - _, err := RandomImageSize(0, []byte("gitea@local")) - assert.Error(t, err) - - _, err = RandomImageSize(64, []byte("gitea@local")) - assert.NoError(t, err) -} - -func Test_RandomImage(t *testing.T) { - _, err := RandomImage([]byte("gitea@local")) - assert.NoError(t, err) -} - func Test_ProcessAvatarPNG(t *testing.T) { setting.Avatar.MaxWidth = 4096 setting.Avatar.MaxHeight = 4096 @@ -134,3 +121,18 @@ func Test_ProcessAvatarImage(t *testing.T) { _, err = processAvatarImage(origin, 262144) assert.ErrorContains(t, err, "image width is too large: 10 > 5") } + +func BenchmarkRandomImage(b *testing.B) { + b.Run("size-48", func(b *testing.B) { + for b.Loop() { + // BenchmarkRandomImage/size-48-12 49549 22899 ns/op + RandomImageWithSize(48, []byte("test-content")) + } + }) + b.Run("size-96", func(b *testing.B) { + for b.Loop() { + // BenchmarkRandomImage/size-96-12 13816 88187 ns/op + RandomImageWithSize(96, []byte("test-content")) + } + }) +} diff --git a/modules/avatar/identicon/identicon.go b/modules/avatar/identicon/identicon.go index 19f87da85af..a1a0f0d7bfe 100644 --- a/modules/avatar/identicon/identicon.go +++ b/modules/avatar/identicon/identicon.go @@ -8,13 +8,14 @@ package identicon import ( "crypto/sha256" - "errors" - "fmt" "image" "image/color" ) -const minImageSize = 16 +const ( + minImageSize = 16 + maxImageSize = 2048 +) // Identicon is used to generate pseudo-random avatars type Identicon struct { @@ -24,25 +25,17 @@ type Identicon struct { rect image.Rectangle } -// New returns an Identicon struct with the correct settings -// size image size -// back background color -// fore all possible foreground colors. only one foreground color will be picked randomly for one image -func New(size int, back color.Color, fore ...color.Color) (*Identicon, error) { - if len(fore) == 0 { - return nil, errors.New("foreground is not set") - } - - if size < minImageSize { - return nil, fmt.Errorf("size %d is smaller than min size %d", size, minImageSize) - } - +// New returns an Identicon struct. +// Only one foreground color will be picked randomly for one image. +func New(size int, backColor color.Color, foreColors []color.Color) *Identicon { + size = max(size, minImageSize) + size = min(size, maxImageSize) return &Identicon{ - foreColors: fore, - backColor: back, + foreColors: foreColors, + backColor: backColor, size: size, rect: image.Rect(0, 0, size, size), - }, nil + } } // Make generates an avatar by data diff --git a/modules/avatar/identicon/identicon_test.go b/modules/avatar/identicon/identicon_test.go index 23bcc73e2e6..6af4b41cce7 100644 --- a/modules/avatar/identicon/identicon_test.go +++ b/modules/avatar/identicon/identicon_test.go @@ -23,7 +23,7 @@ func TestGenerate(t *testing.T) { } backColor := color.White - imgMaker, err := New(64, backColor, DarkColors...) + imgMaker, err := New(64, backColor, DarkColors) assert.NoError(t, err) for i := 0; i < 100; i++ { s := strconv.Itoa(i) diff --git a/modules/base/natural_sort.go b/modules/base/natural_sort.go index acb90022764..d1ee7b04ec3 100644 --- a/modules/base/natural_sort.go +++ b/modules/base/natural_sort.go @@ -41,8 +41,8 @@ func naturalSortAdvance(str string, pos int) (end int, isNumber bool) { return end, isNumber } -// NaturalSortLess compares two strings so that they could be sorted in natural order -func NaturalSortLess(s1, s2 string) bool { +// NaturalSortCompare compares two strings so that they could be sorted in natural order +func NaturalSortCompare(s1, s2 string) int { // There is a bug in Golang's collate package: https://github.com/golang/go/issues/67997 // text/collate: CompareString(collate.Numeric) returns wrong result for "0.0" vs "1.0" #67997 // So we need to handle the number parts by ourselves @@ -55,16 +55,16 @@ func NaturalSortLess(s1, s2 string) bool { if isNum1 && isNum2 { if part1 != part2 { if len(part1) != len(part2) { - return len(part1) < len(part2) + return len(part1) - len(part2) } - return part1 < part2 + return c.CompareString(part1, part2) } } else { if cmp := c.CompareString(part1, part2); cmp != 0 { - return cmp < 0 + return cmp } } pos1, pos2 = end1, end2 } - return len(s1) < len(s2) + return len(s1) - len(s2) } diff --git a/modules/base/natural_sort_test.go b/modules/base/natural_sort_test.go index b001bc4ac97..451aba6618b 100644 --- a/modules/base/natural_sort_test.go +++ b/modules/base/natural_sort_test.go @@ -11,12 +11,10 @@ import ( func TestNaturalSortLess(t *testing.T) { testLess := func(s1, s2 string) { - assert.True(t, NaturalSortLess(s1, s2), "s1= len(content) { - break + if opts.ErrorReturnOrigin { + return content } + if opts.ErrorReplacement == nil { + decoded = append(decoded, content[idx+n]) + } else { + decoded = append(decoded, opts.ErrorReplacement...) + } + idx += n + 1 } - - return MaybeRemoveBOM(decoded, opts) + return maybeRemoveBOM(decoded, opts) } -// MaybeRemoveBOM removes a UTF-8 BOM from a []byte when opts.KeepBOM is false -func MaybeRemoveBOM(content []byte, opts ConvertOpts) []byte { +// maybeRemoveBOM removes a UTF-8 BOM from a []byte when opts.KeepBOM is false +func maybeRemoveBOM(content []byte, opts ConvertOpts) []byte { if opts.KeepBOM { return content } - if len(content) > 2 && bytes.Equal(content[0:3], UTF8BOM) { - return content[3:] - } - return content + return bytes.TrimPrefix(content, UTF8BOM) } // DetectEncoding detect the encoding of content -func DetectEncoding(content []byte) (string, error) { +// it always returns a detected or guessed "encoding" string, no matter error happens or not +func DetectEncoding(content []byte) (encoding string, _ error) { // First we check if the content represents valid utf8 content excepting a truncated character at the end. // Now we could decode all the runes in turn but this is not necessarily the cheapest thing to do - // instead we walk backwards from the end to trim off a the incomplete character + // instead we walk backwards from the end to trim off the incomplete character toValidate := content end := len(toValidate) - 1 - if end < 0 { - // no-op - } else if toValidate[end]>>5 == 0b110 { - // Incomplete 1 byte extension e.g. © which has been truncated to - toValidate = toValidate[:end] - } else if end > 0 && toValidate[end]>>6 == 0b10 && toValidate[end-1]>>4 == 0b1110 { - // Incomplete 2 byte extension e.g. ⛔ <9b><94> which has been truncated to <9b> - toValidate = toValidate[:end-1] - } else if end > 1 && toValidate[end]>>6 == 0b10 && toValidate[end-1]>>6 == 0b10 && toValidate[end-2]>>3 == 0b11110 { - // Incomplete 3 byte extension e.g. 💩 <9f><92> which has been truncated to <9f><92> - toValidate = toValidate[:end-2] + // U+0000 U+007F 0yyyzzzz + // U+0080 U+07FF 110xxxyy 10yyzzzz + // U+0800 U+FFFF 1110wwww 10xxxxyy 10yyzzzz + // U+010000 U+10FFFF 11110uvv 10vvwwww 10xxxxyy 10yyzzzz + cnt := 0 + for end >= 0 && cnt < 4 { + c := toValidate[end] + if c>>5 == 0b110 || c>>4 == 0b1110 || c>>3 == 0b11110 { + // a leading byte + toValidate = toValidate[:end] + break + } else if c>>6 == 0b10 { + // a continuation byte + end-- + } else { + // not an utf-8 byte + break + } + cnt++ } + if utf8.Valid(toValidate) { - log.Debug("Detected encoding: utf-8 (fast)") return "UTF-8", nil } @@ -160,7 +148,7 @@ func DetectEncoding(content []byte) (string, error) { if len(content) < 1024 { // Check if original content is valid if _, err := textDetector.DetectBest(content); err != nil { - return "", err + return util.IfZero(setting.Repository.AnsiCharset, "UTF-8"), err } times := 1024 / len(content) detectContent = make([]byte, 0, times*len(content)) @@ -171,14 +159,10 @@ func DetectEncoding(content []byte) (string, error) { detectContent = content } - // Now we can't use DetectBest or just results[0] because the result isn't stable - so we need a tie break + // Now we can't use DetectBest or just results[0] because the result isn't stable - so we need a tie-break results, err := textDetector.DetectAll(detectContent) if err != nil { - if err == chardet.NotDetectedError && len(setting.Repository.AnsiCharset) > 0 { - log.Debug("Using default AnsiCharset: %s", setting.Repository.AnsiCharset) - return setting.Repository.AnsiCharset, nil - } - return "", err + return util.IfZero(setting.Repository.AnsiCharset, "UTF-8"), err } topConfidence := results[0].Confidence @@ -201,11 +185,9 @@ func DetectEncoding(content []byte) (string, error) { } // FIXME: to properly decouple this function the fallback ANSI charset should be passed as an argument - if topResult.Charset != "UTF-8" && len(setting.Repository.AnsiCharset) > 0 { - log.Debug("Using default AnsiCharset: %s", setting.Repository.AnsiCharset) + if topResult.Charset != "UTF-8" && setting.Repository.AnsiCharset != "" { return setting.Repository.AnsiCharset, err } - log.Debug("Detected encoding: %s", topResult.Charset) - return topResult.Charset, err + return topResult.Charset, nil } diff --git a/modules/charset/charset_test.go b/modules/charset/charset_test.go index cd2e3b9aaa4..0314abc347b 100644 --- a/modules/charset/charset_test.go +++ b/modules/charset/charset_test.go @@ -4,108 +4,89 @@ package charset import ( - "bytes" "io" + "os" "strings" "testing" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" "github.com/stretchr/testify/assert" ) -func resetDefaultCharsetsOrder() { - defaultDetectedCharsetsOrder := make([]string, 0, len(setting.Repository.DetectedCharsetsOrder)) - for _, charset := range setting.Repository.DetectedCharsetsOrder { - defaultDetectedCharsetsOrder = append(defaultDetectedCharsetsOrder, strings.ToLower(strings.TrimSpace(charset))) - } +func TestMain(m *testing.M) { setting.Repository.DetectedCharsetScore = map[string]int{} - i := 0 - for _, charset := range defaultDetectedCharsetsOrder { - canonicalCharset := strings.ToLower(strings.TrimSpace(charset)) - if _, has := setting.Repository.DetectedCharsetScore[canonicalCharset]; !has { - setting.Repository.DetectedCharsetScore[canonicalCharset] = i - i++ - } + for i, charset := range setting.Repository.DetectedCharsetsOrder { + setting.Repository.DetectedCharsetScore[strings.ToLower(charset)] = i } + os.Exit(m.Run()) } func TestMaybeRemoveBOM(t *testing.T) { - res := MaybeRemoveBOM([]byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, ConvertOpts{}) + res := maybeRemoveBOM([]byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, ConvertOpts{}) assert.Equal(t, []byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, res) - res = MaybeRemoveBOM([]byte{0xef, 0xbb, 0xbf, 0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, ConvertOpts{}) + res = maybeRemoveBOM([]byte{0xef, 0xbb, 0xbf, 0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, ConvertOpts{}) assert.Equal(t, []byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, res) } func TestToUTF8(t *testing.T) { - resetDefaultCharsetsOrder() - // Note: golang compiler seems so behave differently depending on the current // locale, so some conversions might behave differently. For that reason, we don't // depend on particular conversions but in expected behaviors. - res, err := ToUTF8([]byte{0x41, 0x42, 0x43}, ConvertOpts{}) - assert.NoError(t, err) - assert.Equal(t, "ABC", res) + res := ToUTF8([]byte{0x41, 0x42, 0x43}, ConvertOpts{}) + assert.Equal(t, "ABC", string(res)) // "áéíóú" - res, err = ToUTF8([]byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, ConvertOpts{}) - assert.NoError(t, err) - assert.Equal(t, []byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, []byte(res)) + res = ToUTF8([]byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, ConvertOpts{}) + assert.Equal(t, []byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, res) // "áéíóú" - res, err = ToUTF8([]byte{ + res = ToUTF8([]byte{ 0xef, 0xbb, 0xbf, 0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba, }, ConvertOpts{}) - assert.NoError(t, err) - assert.Equal(t, []byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, []byte(res)) + assert.Equal(t, []byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, res) - res, err = ToUTF8([]byte{ + res = ToUTF8([]byte{ 0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63, 0xF3, 0x6D, 0x6F, 0x20, 0xF1, 0x6F, 0x73, 0x41, 0x41, 0x41, 0x2e, }, ConvertOpts{}) - assert.NoError(t, err) stringMustStartWith(t, "Hola,", res) stringMustEndWith(t, "AAA.", res) - res, err = ToUTF8([]byte{ + res = ToUTF8([]byte{ 0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63, 0xF3, 0x6D, 0x6F, 0x20, 0x07, 0xA4, 0x6F, 0x73, 0x41, 0x41, 0x41, 0x2e, }, ConvertOpts{}) - assert.NoError(t, err) stringMustStartWith(t, "Hola,", res) stringMustEndWith(t, "AAA.", res) - res, err = ToUTF8([]byte{ + res = ToUTF8([]byte{ 0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63, 0xF3, 0x6D, 0x6F, 0x20, 0x81, 0xA4, 0x6F, 0x73, 0x41, 0x41, 0x41, 0x2e, }, ConvertOpts{}) - assert.NoError(t, err) stringMustStartWith(t, "Hola,", res) stringMustEndWith(t, "AAA.", res) // Japanese (Shift-JIS) // 日属秘ぞしちゅ。 - res, err = ToUTF8([]byte{ + res = ToUTF8([]byte{ 0x93, 0xFA, 0x91, 0xAE, 0x94, 0xE9, 0x82, 0xBC, 0x82, 0xB5, 0x82, 0xBF, 0x82, 0xE3, 0x81, 0x42, }, ConvertOpts{}) - assert.NoError(t, err) assert.Equal(t, []byte{ 0xE6, 0x97, 0xA5, 0xE5, 0xB1, 0x9E, 0xE7, 0xA7, 0x98, 0xE3, 0x81, 0x9E, 0xE3, 0x81, 0x97, 0xE3, 0x81, 0xA1, 0xE3, 0x82, 0x85, 0xE3, 0x80, 0x82, - }, - []byte(res)) + }, res) - res, err = ToUTF8([]byte{0x00, 0x00, 0x00, 0x00}, ConvertOpts{}) - assert.NoError(t, err) - assert.Equal(t, []byte{0x00, 0x00, 0x00, 0x00}, []byte(res)) + res = ToUTF8([]byte{0x00, 0x00, 0x00, 0x00}, ConvertOpts{}) + assert.Equal(t, []byte{0x00, 0x00, 0x00, 0x00}, res) } func TestToUTF8WithFallback(t *testing.T) { - resetDefaultCharsetsOrder() // "ABC" res := ToUTF8WithFallback([]byte{0x41, 0x42, 0x43}, ConvertOpts{}) assert.Equal(t, []byte{0x41, 0x42, 0x43}, res) @@ -152,54 +133,58 @@ func TestToUTF8WithFallback(t *testing.T) { } func TestToUTF8DropErrors(t *testing.T) { - resetDefaultCharsetsOrder() // "ABC" - res := ToUTF8DropErrors([]byte{0x41, 0x42, 0x43}, ConvertOpts{}) + res := ToUTF8DropErrors([]byte{0x41, 0x42, 0x43}) assert.Equal(t, []byte{0x41, 0x42, 0x43}, res) // "áéíóú" - res = ToUTF8DropErrors([]byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, ConvertOpts{}) + res = ToUTF8DropErrors([]byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}) assert.Equal(t, []byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, res) // UTF8 BOM + "áéíóú" - res = ToUTF8DropErrors([]byte{0xef, 0xbb, 0xbf, 0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, ConvertOpts{}) + res = ToUTF8DropErrors([]byte{0xef, 0xbb, 0xbf, 0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}) assert.Equal(t, []byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, res) // "Hola, así cómo ños" - res = ToUTF8DropErrors([]byte{0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63, 0xF3, 0x6D, 0x6F, 0x20, 0xF1, 0x6F, 0x73}, ConvertOpts{}) + res = ToUTF8DropErrors([]byte{0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63, 0xF3, 0x6D, 0x6F, 0x20, 0xF1, 0x6F, 0x73}) assert.Equal(t, []byte{0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73}, res[:8]) assert.Equal(t, []byte{0x73}, res[len(res)-1:]) // "Hola, así cómo " minmatch := []byte{0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xC3, 0xAD, 0x20, 0x63, 0xC3, 0xB3, 0x6D, 0x6F, 0x20} - res = ToUTF8DropErrors([]byte{0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63, 0xF3, 0x6D, 0x6F, 0x20, 0x07, 0xA4, 0x6F, 0x73}, ConvertOpts{}) + res = ToUTF8DropErrors([]byte{0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63, 0xF3, 0x6D, 0x6F, 0x20, 0x07, 0xA4, 0x6F, 0x73}) // Do not fail for differences in invalid cases, as the library might change the conversion criteria for those assert.Equal(t, minmatch, res[0:len(minmatch)]) - res = ToUTF8DropErrors([]byte{0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63, 0xF3, 0x6D, 0x6F, 0x20, 0x81, 0xA4, 0x6F, 0x73}, ConvertOpts{}) + res = ToUTF8DropErrors([]byte{0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63, 0xF3, 0x6D, 0x6F, 0x20, 0x81, 0xA4, 0x6F, 0x73}) // Do not fail for differences in invalid cases, as the library might change the conversion criteria for those assert.Equal(t, minmatch, res[0:len(minmatch)]) // Japanese (Shift-JIS) // "日属秘ぞしちゅ。" - res = ToUTF8DropErrors([]byte{0x93, 0xFA, 0x91, 0xAE, 0x94, 0xE9, 0x82, 0xBC, 0x82, 0xB5, 0x82, 0xBF, 0x82, 0xE3, 0x81, 0x42}, ConvertOpts{}) + res = ToUTF8DropErrors([]byte{0x93, 0xFA, 0x91, 0xAE, 0x94, 0xE9, 0x82, 0xBC, 0x82, 0xB5, 0x82, 0xBF, 0x82, 0xE3, 0x81, 0x42}) assert.Equal(t, []byte{ 0xE6, 0x97, 0xA5, 0xE5, 0xB1, 0x9E, 0xE7, 0xA7, 0x98, 0xE3, 0x81, 0x9E, 0xE3, 0x81, 0x97, 0xE3, 0x81, 0xA1, 0xE3, 0x82, 0x85, 0xE3, 0x80, 0x82, }, res) - res = ToUTF8DropErrors([]byte{0x00, 0x00, 0x00, 0x00}, ConvertOpts{}) + res = ToUTF8DropErrors([]byte{0x00, 0x00, 0x00, 0x00}) assert.Equal(t, []byte{0x00, 0x00, 0x00, 0x00}, res) } func TestDetectEncoding(t *testing.T) { - resetDefaultCharsetsOrder() testSuccess := func(b []byte, expected string) { encoding, err := DetectEncoding(b) assert.NoError(t, err) assert.Equal(t, expected, encoding) } + + // invalid bytes + encoding, err := DetectEncoding([]byte{0xfa}) + assert.Error(t, err) + assert.Equal(t, "UTF-8", encoding) + // utf-8 b := []byte("just some ascii") testSuccess(b, "UTF-8") @@ -214,169 +199,49 @@ func TestDetectEncoding(t *testing.T) { // iso-8859-1: dcor b = []byte{0x44, 0xe9, 0x63, 0x6f, 0x72, 0x0a} - encoding, err := DetectEncoding(b) + encoding, err = DetectEncoding(b) assert.NoError(t, err) assert.Contains(t, encoding, "ISO-8859-1") - old := setting.Repository.AnsiCharset - setting.Repository.AnsiCharset = "placeholder" - defer func() { - setting.Repository.AnsiCharset = old - }() - testSuccess(b, "placeholder") - - // invalid bytes - b = []byte{0xfa} - _, err = DetectEncoding(b) - assert.Error(t, err) + defer test.MockVariableValue(&setting.Repository.AnsiCharset, "MyEncoding")() + testSuccess(b, "MyEncoding") } -func stringMustStartWith(t *testing.T, expected, value string) { - assert.Equal(t, expected, value[:len(expected)]) +func stringMustStartWith(t *testing.T, expected string, value []byte) { + assert.Equal(t, expected, string(value[:len(expected)])) } -func stringMustEndWith(t *testing.T, expected, value string) { - assert.Equal(t, expected, value[len(value)-len(expected):]) +func stringMustEndWith(t *testing.T, expected string, value []byte) { + assert.Equal(t, expected, string(value[len(value)-len(expected):])) } func TestToUTF8WithFallbackReader(t *testing.T) { - resetDefaultCharsetsOrder() + test.MockVariableValue(&ToUTF8WithFallbackReaderPrefetchSize) - for testLen := range 2048 { - pattern := " test { () }\n" - input := "" - for len(input) < testLen { - input += pattern - } - input = input[:testLen] - input += "// Выключаем" - rd := ToUTF8WithFallbackReader(bytes.NewReader([]byte(input)), ConvertOpts{}) + block := "aá啊🤔" + runes := []rune(block) + assert.Len(t, string(runes[0]), 1) + assert.Len(t, string(runes[1]), 2) + assert.Len(t, string(runes[2]), 3) + assert.Len(t, string(runes[3]), 4) + + content := strings.Repeat(block, 2) + for i := 1; i < len(content); i++ { + encoding, err := DetectEncoding([]byte(content[:i])) + assert.NoError(t, err) + assert.Equal(t, "UTF-8", encoding) + + ToUTF8WithFallbackReaderPrefetchSize = i + rd := ToUTF8WithFallbackReader(strings.NewReader(content), ConvertOpts{}) r, _ := io.ReadAll(rd) - assert.Equalf(t, input, string(r), "testing string len=%d", testLen) + assert.Equal(t, content, string(r)) + } + for _, r := range runes { + content = "abc abc " + string(r) + string(r) + string(r) + for i := 0; i < len(content); i++ { + encoding, err := DetectEncoding([]byte(content[:i])) + assert.NoError(t, err) + assert.Equal(t, "UTF-8", encoding) + } } - - truncatedOneByteExtension := failFastBytes - encoding, _ := DetectEncoding(truncatedOneByteExtension) - assert.Equal(t, "UTF-8", encoding) - - truncatedTwoByteExtension := failFastBytes - truncatedTwoByteExtension[len(failFastBytes)-1] = 0x9b - truncatedTwoByteExtension[len(failFastBytes)-2] = 0xe2 - - encoding, _ = DetectEncoding(truncatedTwoByteExtension) - assert.Equal(t, "UTF-8", encoding) - - truncatedThreeByteExtension := failFastBytes - truncatedThreeByteExtension[len(failFastBytes)-1] = 0x92 - truncatedThreeByteExtension[len(failFastBytes)-2] = 0x9f - truncatedThreeByteExtension[len(failFastBytes)-3] = 0xf0 - - encoding, _ = DetectEncoding(truncatedThreeByteExtension) - assert.Equal(t, "UTF-8", encoding) -} - -var failFastBytes = []byte{ - 0x69, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x20, 0x6f, 0x72, 0x67, 0x2e, 0x61, 0x70, 0x61, 0x63, 0x68, 0x65, 0x2e, 0x74, 0x6f, - 0x6f, 0x6c, 0x73, 0x2e, 0x61, 0x6e, 0x74, 0x2e, 0x74, 0x61, 0x73, 0x6b, 0x64, 0x65, 0x66, 0x73, 0x2e, 0x63, 0x6f, 0x6e, - 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x4f, 0x73, 0x0a, 0x69, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x20, 0x6f, 0x72, 0x67, - 0x2e, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x77, 0x6f, 0x72, 0x6b, 0x2e, 0x62, 0x6f, 0x6f, - 0x74, 0x2e, 0x67, 0x72, 0x61, 0x64, 0x6c, 0x65, 0x2e, 0x74, 0x61, 0x73, 0x6b, 0x73, 0x2e, 0x72, 0x75, 0x6e, 0x2e, 0x42, - 0x6f, 0x6f, 0x74, 0x52, 0x75, 0x6e, 0x0a, 0x0a, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x73, 0x20, 0x7b, 0x0a, 0x20, 0x20, - 0x20, 0x20, 0x69, 0x64, 0x28, 0x22, 0x6f, 0x72, 0x67, 0x2e, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d, - 0x65, 0x77, 0x6f, 0x72, 0x6b, 0x2e, 0x62, 0x6f, 0x6f, 0x74, 0x22, 0x29, 0x0a, 0x7d, 0x0a, 0x0a, 0x64, 0x65, 0x70, 0x65, - 0x6e, 0x64, 0x65, 0x6e, 0x63, 0x69, 0x65, 0x73, 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x28, 0x22, 0x3a, - 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x3a, 0x61, 0x70, 0x69, 0x22, 0x29, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, - 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, - 0x28, 0x22, 0x3a, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x3a, 0x61, 0x70, 0x69, 0x2d, 0x64, 0x6f, 0x63, 0x73, 0x22, 0x29, - 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x28, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x28, 0x22, 0x3a, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x3a, 0x64, 0x62, - 0x22, 0x29, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, - 0x6f, 0x6e, 0x28, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x28, 0x22, 0x3a, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x3a, - 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x22, 0x29, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x28, 0x22, 0x3a, - 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x3a, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2d, 0x66, - 0x73, 0x22, 0x29, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, - 0x69, 0x6f, 0x6e, 0x28, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x28, 0x22, 0x3a, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, - 0x3a, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2d, 0x6d, 0x71, 0x22, 0x29, 0x29, 0x0a, 0x0a, - 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, - 0x6a, 0x66, 0x75, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x65, 0x3a, 0x70, 0x65, 0x2d, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, - 0x2d, 0x61, 0x75, 0x74, 0x68, 0x2d, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2d, 0x73, 0x74, 0x61, 0x72, 0x74, - 0x65, 0x72, 0x22, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, - 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x6a, 0x66, 0x75, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x65, 0x3a, 0x70, 0x65, 0x2d, 0x63, - 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2d, 0x68, 0x61, 0x6c, 0x22, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x6a, 0x66, 0x75, 0x73, 0x69, 0x6f, 0x6e, 0x2e, - 0x70, 0x65, 0x3a, 0x70, 0x65, 0x2d, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2d, 0x63, 0x6f, 0x72, 0x65, 0x22, 0x29, 0x0a, - 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, - 0x22, 0x6f, 0x72, 0x67, 0x2e, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x77, 0x6f, 0x72, 0x6b, - 0x2e, 0x62, 0x6f, 0x6f, 0x74, 0x3a, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x2d, 0x62, 0x6f, 0x6f, 0x74, 0x2d, 0x73, 0x74, - 0x61, 0x72, 0x74, 0x65, 0x72, 0x2d, 0x77, 0x65, 0x62, 0x22, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x6f, 0x72, 0x67, 0x2e, 0x73, 0x70, 0x72, 0x69, - 0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x77, 0x6f, 0x72, 0x6b, 0x2e, 0x62, 0x6f, 0x6f, 0x74, 0x3a, 0x73, 0x70, 0x72, - 0x69, 0x6e, 0x67, 0x2d, 0x62, 0x6f, 0x6f, 0x74, 0x2d, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x72, 0x2d, 0x61, 0x6f, 0x70, - 0x22, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, - 0x6e, 0x28, 0x22, 0x6f, 0x72, 0x67, 0x2e, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x77, 0x6f, - 0x72, 0x6b, 0x2e, 0x62, 0x6f, 0x6f, 0x74, 0x3a, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x2d, 0x62, 0x6f, 0x6f, 0x74, 0x2d, - 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x72, 0x2d, 0x61, 0x63, 0x74, 0x75, 0x61, 0x74, 0x6f, 0x72, 0x22, 0x29, 0x0a, 0x20, - 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x6f, - 0x72, 0x67, 0x2e, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x77, 0x6f, 0x72, 0x6b, 0x2e, 0x63, - 0x6c, 0x6f, 0x75, 0x64, 0x3a, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x2d, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, 0x73, 0x74, - 0x61, 0x72, 0x74, 0x65, 0x72, 0x2d, 0x62, 0x6f, 0x6f, 0x74, 0x73, 0x74, 0x72, 0x61, 0x70, 0x22, 0x29, 0x0a, 0x20, 0x20, - 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x6f, 0x72, - 0x67, 0x2e, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x77, 0x6f, 0x72, 0x6b, 0x2e, 0x63, 0x6c, - 0x6f, 0x75, 0x64, 0x3a, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x2d, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, 0x73, 0x74, 0x61, - 0x72, 0x74, 0x65, 0x72, 0x2d, 0x63, 0x6f, 0x6e, 0x73, 0x75, 0x6c, 0x2d, 0x61, 0x6c, 0x6c, 0x22, 0x29, 0x0a, 0x20, 0x20, - 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x6f, 0x72, - 0x67, 0x2e, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x77, 0x6f, 0x72, 0x6b, 0x2e, 0x63, 0x6c, - 0x6f, 0x75, 0x64, 0x3a, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x2d, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, 0x73, 0x74, 0x61, - 0x72, 0x74, 0x65, 0x72, 0x2d, 0x73, 0x6c, 0x65, 0x75, 0x74, 0x68, 0x22, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, - 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x6f, 0x72, 0x67, 0x2e, 0x73, 0x70, - 0x72, 0x69, 0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x77, 0x6f, 0x72, 0x6b, 0x2e, 0x72, 0x65, 0x74, 0x72, 0x79, 0x3a, - 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x2d, 0x72, 0x65, 0x74, 0x72, 0x79, 0x22, 0x29, 0x0a, 0x0a, 0x20, 0x20, 0x20, 0x20, - 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x63, 0x68, 0x2e, 0x71, - 0x6f, 0x73, 0x2e, 0x6c, 0x6f, 0x67, 0x62, 0x61, 0x63, 0x6b, 0x3a, 0x6c, 0x6f, 0x67, 0x62, 0x61, 0x63, 0x6b, 0x2d, 0x63, - 0x6c, 0x61, 0x73, 0x73, 0x69, 0x63, 0x22, 0x29, 0x0a, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x69, 0x6f, 0x2e, 0x6d, 0x69, 0x63, 0x72, 0x6f, 0x6d, 0x65, - 0x74, 0x65, 0x72, 0x3a, 0x6d, 0x69, 0x63, 0x72, 0x6f, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x2d, 0x72, 0x65, 0x67, 0x69, 0x73, - 0x74, 0x72, 0x79, 0x2d, 0x70, 0x72, 0x6f, 0x6d, 0x65, 0x74, 0x68, 0x65, 0x75, 0x73, 0x22, 0x29, 0x0a, 0x0a, 0x20, 0x20, - 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x6b, 0x6f, 0x74, - 0x6c, 0x69, 0x6e, 0x28, 0x22, 0x73, 0x74, 0x64, 0x6c, 0x69, 0x62, 0x22, 0x29, 0x29, 0x0a, 0x0a, 0x20, 0x20, 0x20, 0x20, - 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, - 0x2f, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x2f, 0x2f, 0x20, 0x54, 0x65, 0x73, 0x74, 0x20, 0x64, 0x65, 0x70, 0x65, 0x6e, 0x64, - 0x65, 0x6e, 0x63, 0x69, 0x65, 0x73, 0x2e, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, - 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x0a, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x74, - 0x65, 0x73, 0x74, 0x49, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x6a, - 0x66, 0x75, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x65, 0x3a, 0x70, 0x65, 0x2d, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2d, - 0x74, 0x65, 0x73, 0x74, 0x22, 0x29, 0x0a, 0x7d, 0x0a, 0x0a, 0x76, 0x61, 0x6c, 0x20, 0x70, 0x61, 0x74, 0x63, 0x68, 0x4a, - 0x61, 0x72, 0x20, 0x62, 0x79, 0x20, 0x74, 0x61, 0x73, 0x6b, 0x73, 0x2e, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, - 0x69, 0x6e, 0x67, 0x28, 0x4a, 0x61, 0x72, 0x3a, 0x3a, 0x63, 0x6c, 0x61, 0x73, 0x73, 0x29, 0x20, 0x7b, 0x0a, 0x20, 0x20, - 0x20, 0x20, 0x61, 0x72, 0x63, 0x68, 0x69, 0x76, 0x65, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x69, 0x66, 0x69, 0x65, 0x72, 0x2e, - 0x73, 0x65, 0x74, 0x28, 0x22, 0x70, 0x61, 0x74, 0x63, 0x68, 0x65, 0x64, 0x22, 0x29, 0x0a, 0x0a, 0x20, 0x20, 0x20, 0x20, - 0x76, 0x61, 0x6c, 0x20, 0x72, 0x75, 0x6e, 0x74, 0x69, 0x6d, 0x65, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x70, 0x61, 0x74, 0x68, - 0x20, 0x62, 0x79, 0x20, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2e, 0x67, - 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x0a, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x6d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, - 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, - 0x73, 0x28, 0x22, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x2d, 0x50, 0x61, 0x74, 0x68, 0x22, 0x20, 0x74, 0x6f, 0x20, 0x6f, 0x62, - 0x6a, 0x65, 0x63, 0x74, 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x70, - 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x20, 0x76, 0x61, 0x6c, 0x20, 0x70, 0x61, 0x74, 0x74, 0x65, 0x72, 0x6e, 0x20, 0x3d, - 0x20, 0x22, 0x66, 0x69, 0x6c, 0x65, 0x3a, 0x2f, 0x2b, 0x22, 0x2e, 0x74, 0x6f, 0x52, 0x65, 0x67, 0x65, 0x78, 0x28, 0x29, - 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x6f, 0x76, 0x65, 0x72, 0x72, 0x69, 0x64, - 0x65, 0x20, 0x66, 0x75, 0x6e, 0x20, 0x74, 0x6f, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x28, 0x29, 0x3a, 0x20, 0x53, 0x74, - 0x72, 0x69, 0x6e, 0x67, 0x20, 0x3d, 0x20, 0x72, 0x75, 0x6e, 0x74, 0x69, 0x6d, 0x65, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x70, - 0x61, 0x74, 0x68, 0x2e, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x2e, 0x6a, 0x6f, 0x69, 0x6e, 0x54, 0x6f, 0x53, 0x74, 0x72, 0x69, - 0x6e, 0x67, 0x28, 0x22, 0x20, 0x22, 0x29, 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, - 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x69, 0x74, 0x2e, 0x74, 0x6f, 0x55, 0x52, 0x49, 0x28, 0x29, 0x2e, 0x74, 0x6f, 0x55, - 0x52, 0x4c, 0x28, 0x29, 0x2e, 0x74, 0x6f, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x28, 0x29, 0x2e, 0x72, 0x65, 0x70, 0x6c, - 0x61, 0x63, 0x65, 0x46, 0x69, 0x72, 0x73, 0x74, 0x28, 0x70, 0x61, 0x74, 0x74, 0x65, 0x72, 0x6e, 0x2c, 0x20, 0x22, 0x2f, - 0x22, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x7d, 0x0a, 0x20, 0x20, 0x20, - 0x20, 0x20, 0x20, 0x20, 0x20, 0x7d, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x7d, 0x0a, 0x7d, 0x0a, 0x0a, 0x74, 0x61, 0x73, - 0x6b, 0x73, 0x2e, 0x6e, 0x61, 0x6d, 0x65, 0x64, 0x3c, 0x42, 0x6f, 0x6f, 0x74, 0x52, 0x75, 0x6e, 0x3e, 0x28, 0x22, 0x62, - 0x6f, 0x6f, 0x74, 0x52, 0x75, 0x6e, 0x22, 0x29, 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x66, 0x20, 0x28, 0x4f, - 0x73, 0x2e, 0x69, 0x73, 0x46, 0x61, 0x6d, 0x69, 0x6c, 0x79, 0x28, 0x4f, 0x73, 0x2e, 0x46, 0x41, 0x4d, 0x49, 0x4c, 0x59, - 0x5f, 0x57, 0x49, 0x4e, 0x44, 0x4f, 0x57, 0x53, 0x29, 0x29, 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, - 0x20, 0x63, 0x6c, 0x61, 0x73, 0x73, 0x70, 0x61, 0x74, 0x68, 0x20, 0x3d, 0x20, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x28, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x74, 0x73, 0x2e, 0x6e, 0x61, 0x6d, 0x65, 0x64, 0x28, 0x22, 0x6d, 0x61, 0x69, - 0x6e, 0x22, 0x29, 0x2e, 0x6d, 0x61, 0x70, 0x20, 0x7b, 0x20, 0x69, 0x74, 0x2e, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x20, - 0x7d, 0x2c, 0x20, 0x70, 0x61, 0x74, 0x63, 0x68, 0x4a, 0x61, 0x72, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x7d, 0x0a, 0x0a, - 0x20, 0x20, 0x20, 0x20, 0x2f, 0x2f, 0x20, 0xd0, } diff --git a/modules/charset/escape.go b/modules/charset/escape.go index 92e417d1f72..167683a298b 100644 --- a/modules/charset/escape.go +++ b/modules/charset/escape.go @@ -20,14 +20,17 @@ import ( // RuneNBSP is the codepoint for NBSP const RuneNBSP = 0xa0 -// EscapeControlHTML escapes the unicode control sequences in a provided html document +// EscapeControlHTML escapes the Unicode control sequences in a provided html document func EscapeControlHTML(html template.HTML, locale translation.Locale, allowed ...rune) (escaped *EscapeStatus, output template.HTML) { + if !setting.UI.AmbiguousUnicodeDetection { + return &EscapeStatus{}, html + } sb := &strings.Builder{} escaped, _ = EscapeControlReader(strings.NewReader(string(html)), sb, locale, allowed...) // err has been handled in EscapeControlReader return escaped, template.HTML(sb.String()) } -// EscapeControlReader escapes the unicode control sequences in a provided reader of HTML content and writer in a locale and returns the findings as an EscapeStatus +// EscapeControlReader escapes the Unicode control sequences in a provided reader of HTML content and writer in a locale and returns the findings as an EscapeStatus func EscapeControlReader(reader io.Reader, writer io.Writer, locale translation.Locale, allowed ...rune) (escaped *EscapeStatus, err error) { if !setting.UI.AmbiguousUnicodeDetection { _, err = io.Copy(writer, reader) diff --git a/modules/charset/escape_stream.go b/modules/charset/escape_stream.go index 29943eb8580..22e7f14f39f 100644 --- a/modules/charset/escape_stream.go +++ b/modules/charset/escape_stream.go @@ -61,12 +61,14 @@ func (e *escapeStreamer) Text(data string) error { until = len(data) next = until } else { - until, next = nextIdxs[0]+pos, nextIdxs[1]+pos + until = min(nextIdxs[0]+pos, len(data)) + next = min(nextIdxs[1]+pos, len(data)) } // from pos until we know that the runes are not \r\t\n or even ' ' - runes := make([]rune, 0, next-until) - positions := make([]int, 0, next-until+1) + n := next - until + runes := make([]rune, 0, n) + positions := make([]int, 0, n+1) for pos < until { r, sz := utf8.DecodeRune(dataBytes[pos:]) diff --git a/modules/emoji/emoji.go b/modules/emoji/emoji.go index 3d4ef8599b7..891a0b9ab34 100644 --- a/modules/emoji/emoji.go +++ b/modules/emoji/emoji.go @@ -8,7 +8,9 @@ import ( "io" "sort" "strings" - "sync" + "sync/atomic" + + "code.gitea.io/gitea/modules/setting" ) // Gemoji is a set of emoji data. @@ -23,74 +25,78 @@ type Emoji struct { SkinTones bool } -var ( - // codeMap provides a map of the emoji unicode code to its emoji data. - codeMap map[string]int +type globalVarsStruct struct { + codeMap map[string]int // emoji unicode code to its emoji data. + aliasMap map[string]int // the alias to its emoji data. + emptyReplacer *strings.Replacer // string replacer for emoji codes, used for finding emoji positions. + codeReplacer *strings.Replacer // string replacer for emoji codes. + aliasReplacer *strings.Replacer // string replacer for emoji aliases. +} - // aliasMap provides a map of the alias to its emoji data. - aliasMap map[string]int +var globalVarsStore atomic.Pointer[globalVarsStruct] - // emptyReplacer is the string replacer for emoji codes. - emptyReplacer *strings.Replacer +func globalVars() *globalVarsStruct { + vars := globalVarsStore.Load() + if vars != nil { + return vars + } + // although there can be concurrent calls, the result should be the same, and there is no performance problem + vars = &globalVarsStruct{} + vars.codeMap = make(map[string]int, len(GemojiData)) + vars.aliasMap = make(map[string]int, len(GemojiData)) - // codeReplacer is the string replacer for emoji codes. - codeReplacer *strings.Replacer + // process emoji codes and aliases + codePairs := make([]string, 0) + emptyPairs := make([]string, 0) + aliasPairs := make([]string, 0) - // aliasReplacer is the string replacer for emoji aliases. - aliasReplacer *strings.Replacer + // sort from largest to small so we match combined emoji first + sort.Slice(GemojiData, func(i, j int) bool { + return len(GemojiData[i].Emoji) > len(GemojiData[j].Emoji) + }) - once sync.Once -) - -func loadMap() { - once.Do(func() { - // initialize - codeMap = make(map[string]int, len(GemojiData)) - aliasMap = make(map[string]int, len(GemojiData)) - - // process emoji codes and aliases - codePairs := make([]string, 0) - emptyPairs := make([]string, 0) - aliasPairs := make([]string, 0) - - // sort from largest to small so we match combined emoji first - sort.Slice(GemojiData, func(i, j int) bool { - return len(GemojiData[i].Emoji) > len(GemojiData[j].Emoji) - }) - - for i, e := range GemojiData { - if e.Emoji == "" || len(e.Aliases) == 0 { - continue - } - - // setup codes - codeMap[e.Emoji] = i - codePairs = append(codePairs, e.Emoji, ":"+e.Aliases[0]+":") - emptyPairs = append(emptyPairs, e.Emoji, e.Emoji) - - // setup aliases - for _, a := range e.Aliases { - if a == "" { - continue - } - - aliasMap[a] = i - aliasPairs = append(aliasPairs, ":"+a+":", e.Emoji) - } + for idx, emoji := range GemojiData { + if emoji.Emoji == "" || len(emoji.Aliases) == 0 { + continue } - // create replacers - emptyReplacer = strings.NewReplacer(emptyPairs...) - codeReplacer = strings.NewReplacer(codePairs...) - aliasReplacer = strings.NewReplacer(aliasPairs...) - }) + // process aliases + firstAlias := "" + for _, alias := range emoji.Aliases { + if alias == "" { + continue + } + enabled := len(setting.UI.EnabledEmojisSet) == 0 || setting.UI.EnabledEmojisSet.Contains(alias) + if !enabled { + continue + } + if firstAlias == "" { + firstAlias = alias + } + vars.aliasMap[alias] = idx + aliasPairs = append(aliasPairs, ":"+alias+":", emoji.Emoji) + } + + // process emoji code + if firstAlias != "" { + vars.codeMap[emoji.Emoji] = idx + codePairs = append(codePairs, emoji.Emoji, ":"+emoji.Aliases[0]+":") + emptyPairs = append(emptyPairs, emoji.Emoji, emoji.Emoji) + } + } + + // create replacers + vars.emptyReplacer = strings.NewReplacer(emptyPairs...) + vars.codeReplacer = strings.NewReplacer(codePairs...) + vars.aliasReplacer = strings.NewReplacer(aliasPairs...) + globalVarsStore.Store(vars) + return vars } // FromCode retrieves the emoji data based on the provided unicode code (ie, // "\u2618" will return the Gemoji data for "shamrock"). func FromCode(code string) *Emoji { - loadMap() - i, ok := codeMap[code] + i, ok := globalVars().codeMap[code] if !ok { return nil } @@ -102,12 +108,11 @@ func FromCode(code string) *Emoji { // "alias" or ":alias:" (ie, "shamrock" or ":shamrock:" will return the Gemoji // data for "shamrock"). func FromAlias(alias string) *Emoji { - loadMap() if strings.HasPrefix(alias, ":") && strings.HasSuffix(alias, ":") { alias = alias[1 : len(alias)-1] } - i, ok := aliasMap[alias] + i, ok := globalVars().aliasMap[alias] if !ok { return nil } @@ -119,15 +124,13 @@ func FromAlias(alias string) *Emoji { // alias (in the form of ":alias:") (ie, "\u2618" will be converted to // ":shamrock:"). func ReplaceCodes(s string) string { - loadMap() - return codeReplacer.Replace(s) + return globalVars().codeReplacer.Replace(s) } // ReplaceAliases replaces all aliases of the form ":alias:" with its // corresponding unicode value. func ReplaceAliases(s string) string { - loadMap() - return aliasReplacer.Replace(s) + return globalVars().aliasReplacer.Replace(s) } type rememberSecondWriteWriter struct { @@ -163,7 +166,6 @@ func (n *rememberSecondWriteWriter) WriteString(s string) (int, error) { // FindEmojiSubmatchIndex returns index pair of longest emoji in a string func FindEmojiSubmatchIndex(s string) []int { - loadMap() secondWriteWriter := rememberSecondWriteWriter{} // A faster and clean implementation would copy the trie tree formation in strings.NewReplacer but @@ -175,7 +177,7 @@ func FindEmojiSubmatchIndex(s string) []int { // Therefore we can simply take the index of the second write as our first emoji // // FIXME: just copy the trie implementation from strings.NewReplacer - _, _ = emptyReplacer.WriteString(&secondWriteWriter, s) + _, _ = globalVars().emptyReplacer.WriteString(&secondWriteWriter, s) // if we wrote less than twice then we never "replaced" if secondWriteWriter.writecount < 2 { diff --git a/modules/emoji/emoji_data.go b/modules/emoji/emoji_data.go index 8d0ae0a43e6..ef25e3d9eae 100644 --- a/modules/emoji/emoji_data.go +++ b/modules/emoji/emoji_data.go @@ -4,7 +4,7 @@ package emoji // Code generated by build/generate-emoji.go. DO NOT EDIT. -// Sourced from https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json +// Sourced from https://raw.githubusercontent.com/rhysd/gemoji/537ff2d7e0496e9964824f7f73ec7ece88c9765a/db/emoji.json var GemojiData = Gemoji{ {"\U0001f44d", "thumbs up", []string{"+1", "thumbsup"}, "6.0", true}, {"\U0001f44d\U0001f3ff", "thumbs up: Dark Skin Tone", []string{"+1_Dark_Skin_Tone"}, "12.0", false}, @@ -345,10 +345,12 @@ var GemojiData = Gemoji{ {"\U0001f1ee\U0001f1f4", "flag: British Indian Ocean Territory", []string{"british_indian_ocean_territory"}, "6.0", false}, {"\U0001f1fb\U0001f1ec", "flag: British Virgin Islands", []string{"british_virgin_islands"}, "6.0", false}, {"\U0001f966", "broccoli", []string{"broccoli"}, "11.0", false}, + {"\u26d3\ufe0f\u200d\U0001f4a5", "broken chain", []string{"broken_chain"}, "15.1", false}, {"\U0001f494", "broken heart", []string{"broken_heart"}, "6.0", false}, {"\U0001f9f9", "broom", []string{"broom"}, "11.0", false}, {"\U0001f7e4", "brown circle", []string{"brown_circle"}, "12.0", false}, {"\U0001f90e", "brown heart", []string{"brown_heart"}, "12.0", false}, + {"\U0001f344\u200d\U0001f7eb", "brown mushroom", []string{"brown_mushroom"}, "15.1", false}, {"\U0001f7eb", "brown square", []string{"brown_square"}, "12.0", false}, {"\U0001f1e7\U0001f1f3", "flag: Brunei", []string{"brunei"}, "6.0", false}, {"\U0001f9cb", "bubble tea", []string{"bubble_tea"}, "13.0", false}, @@ -838,6 +840,7 @@ var GemojiData = Gemoji{ {"\U0001f62e\u200d\U0001f4a8", "face exhaling", []string{"face_exhaling"}, "13.1", false}, {"\U0001f979", "face holding back tears", []string{"face_holding_back_tears"}, "14.0", false}, {"\U0001f636\u200d\U0001f32b\ufe0f", "face in clouds", []string{"face_in_clouds"}, "13.1", false}, + {"\U0001fae9", "face with bags under eyes", []string{"face_with_bags_under_eyes"}, "16.0", false}, {"\U0001fae4", "face with diagonal mouth", []string{"face_with_diagonal_mouth"}, "14.0", false}, {"\U0001f915", "face with head-bandage", []string{"face_with_head_bandage"}, "8.0", false}, {"\U0001fae2", "face with open eyes and hand over mouth", []string{"face_with_open_eyes_and_hand_over_mouth"}, "14.0", false}, @@ -879,6 +882,10 @@ var GemojiData = Gemoji{ {"\U0001f1eb\U0001f1f0", "flag: Falkland Islands", []string{"falkland_islands"}, "6.0", false}, {"\U0001f342", "fallen leaf", []string{"fallen_leaf"}, "6.0", false}, {"\U0001f46a", "family", []string{"family"}, "6.0", false}, + {"\U0001f9d1\u200d\U0001f9d1\u200d\U0001f9d2", "family: adult, adult, child", []string{"family_adult_adult_child"}, "15.1", false}, + {"\U0001f9d1\u200d\U0001f9d1\u200d\U0001f9d2\u200d\U0001f9d2", "family: adult, adult, child, child", []string{"family_adult_adult_child_child"}, "15.1", false}, + {"\U0001f9d1\u200d\U0001f9d2", "family: adult, child", []string{"family_adult_child"}, "15.1", false}, + {"\U0001f9d1\u200d\U0001f9d2\u200d\U0001f9d2", "family: adult, child, child", []string{"family_adult_child_child"}, "15.1", false}, {"\U0001f468\u200d\U0001f466", "family: man, boy", []string{"family_man_boy"}, "6.0", false}, {"\U0001f468\u200d\U0001f466\u200d\U0001f466", "family: man, boy, boy", []string{"family_man_boy_boy"}, "6.0", false}, {"\U0001f468\u200d\U0001f467", "family: man, girl", []string{"family_man_girl"}, "6.0", false}, @@ -931,6 +938,7 @@ var GemojiData = Gemoji{ {"\U0001f4c1", "file folder", []string{"file_folder"}, "6.0", false}, {"\U0001f4fd\ufe0f", "film projector", []string{"film_projector"}, "7.0", false}, {"\U0001f39e\ufe0f", "film frames", []string{"film_strip"}, "7.0", false}, + {"\U0001fac6", "fingerprint", []string{"fingerprint"}, "16.0", false}, {"\U0001f1eb\U0001f1ee", "flag: Finland", []string{"finland"}, "6.0", false}, {"\U0001f525", "fire", []string{"fire"}, "6.0", false}, {"\U0001f692", "fire engine", []string{"fire_engine"}, "6.0", false}, @@ -973,6 +981,7 @@ var GemojiData = Gemoji{ {"\U0001f91c\U0001f3fc", "right-facing fist: Medium-Light Skin Tone", []string{"fist_right_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f91c\U0001f3fd", "right-facing fist: Medium Skin Tone", []string{"fist_right_Medium_Skin_Tone"}, "12.0", false}, {"5\ufe0f\u20e3", "keycap: 5", []string{"five"}, "", false}, + {"\U0001f1e8\U0001f1f6", "flag: Sark", []string{"flag_sark"}, "16.0", false}, {"\U0001f38f", "carp streamer", []string{"flags"}, "6.0", false}, {"\U0001f9a9", "flamingo", []string{"flamingo"}, "12.0", false}, {"\U0001f526", "flashlight", []string{"flashlight"}, "6.0", false}, @@ -1189,9 +1198,12 @@ var GemojiData = Gemoji{ {"\U0001f91d\U0001f3fc", "handshake: Medium-Light Skin Tone", []string{"handshake_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f91d\U0001f3fd", "handshake: Medium Skin Tone", []string{"handshake_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f4a9", "pile of poo", []string{"hankey", "poop", "shit"}, "6.0", false}, + {"\U0001fa89", "harp", []string{"harp"}, "16.0", false}, {"#\ufe0f\u20e3", "keycap: #", []string{"hash"}, "", false}, {"\U0001f425", "front-facing baby chick", []string{"hatched_chick"}, "6.0", false}, {"\U0001f423", "hatching chick", []string{"hatching_chick"}, "6.0", false}, + {"\U0001f642\u200d\u2194\ufe0f", "head shaking horizontally", []string{"head_shaking_horizontally"}, "15.1", false}, + {"\U0001f642\u200d\u2195\ufe0f", "head shaking vertically", []string{"head_shaking_vertically"}, "15.1", false}, {"\U0001f3a7", "headphone", []string{"headphones"}, "6.0", false}, {"\U0001faa6", "headstone", []string{"headstone"}, "13.0", false}, {"\U0001f9d1\u200d\u2695\ufe0f", "health worker", []string{"health_worker"}, "12.1", true}, @@ -1380,6 +1392,7 @@ var GemojiData = Gemoji{ {"\u271d\ufe0f", "latin cross", []string{"latin_cross"}, "", false}, {"\U0001f1f1\U0001f1fb", "flag: Latvia", []string{"latvia"}, "6.0", false}, {"\U0001f606", "grinning squinting face", []string{"laughing", "satisfied", "laugh"}, "6.0", false}, + {"\U0001fabe", "leafless tree", []string{"leafless_tree"}, "16.0", false}, {"\U0001f96c", "leafy green", []string{"leafy_green"}, "11.0", false}, {"\U0001f343", "leaf fluttering in wind", []string{"leaves"}, "6.0", false}, {"\U0001f1f1\U0001f1e7", "flag: Lebanon", []string{"lebanon"}, "6.0", false}, @@ -1417,6 +1430,7 @@ var GemojiData = Gemoji{ {"\U0001f1f1\U0001f1ee", "flag: Liechtenstein", []string{"liechtenstein"}, "6.0", false}, {"\U0001fa75", "light blue heart", []string{"light_blue_heart"}, "15.0", false}, {"\U0001f688", "light rail", []string{"light_rail"}, "6.0", false}, + {"\U0001f34b\u200d\U0001f7e9", "lime", []string{"lime"}, "15.1", false}, {"\U0001f517", "link", []string{"link"}, "6.0", false}, {"\U0001f981", "lion", []string{"lion"}, "8.0", false}, {"\U0001f444", "mouth", []string{"lips"}, "6.0", false}, @@ -1594,12 +1608,24 @@ var GemojiData = Gemoji{ {"\U0001f468\U0001f3fe\u200d\U0001f9bd", "man in manual wheelchair: Medium-Dark Skin Tone", []string{"man_in_manual_wheelchair_Medium-Dark_Skin_Tone"}, "12.0", false}, {"\U0001f468\U0001f3fc\u200d\U0001f9bd", "man in manual wheelchair: Medium-Light Skin Tone", []string{"man_in_manual_wheelchair_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f468\U0001f3fd\u200d\U0001f9bd", "man in manual wheelchair: Medium Skin Tone", []string{"man_in_manual_wheelchair_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f468\u200d\U0001f9bd\u200d\u27a1\ufe0f", "man in manual wheelchair facing right", []string{"man_in_manual_wheelchair_facing_right"}, "15.1", true}, + {"\U0001f468\U0001f3ff\u200d\U0001f9bd\u200d\u27a1\ufe0f", "man in manual wheelchair facing right: Dark Skin Tone", []string{"man_in_manual_wheelchair_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f468\U0001f3fb\u200d\U0001f9bd\u200d\u27a1\ufe0f", "man in manual wheelchair facing right: Light Skin Tone", []string{"man_in_manual_wheelchair_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f468\U0001f3fe\u200d\U0001f9bd\u200d\u27a1\ufe0f", "man in manual wheelchair facing right: Medium-Dark Skin Tone", []string{"man_in_manual_wheelchair_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f468\U0001f3fc\u200d\U0001f9bd\u200d\u27a1\ufe0f", "man in manual wheelchair facing right: Medium-Light Skin Tone", []string{"man_in_manual_wheelchair_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f468\U0001f3fd\u200d\U0001f9bd\u200d\u27a1\ufe0f", "man in manual wheelchair facing right: Medium Skin Tone", []string{"man_in_manual_wheelchair_facing_right_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f468\u200d\U0001f9bc", "man in motorized wheelchair", []string{"man_in_motorized_wheelchair"}, "12.0", true}, {"\U0001f468\U0001f3ff\u200d\U0001f9bc", "man in motorized wheelchair: Dark Skin Tone", []string{"man_in_motorized_wheelchair_Dark_Skin_Tone"}, "12.0", false}, {"\U0001f468\U0001f3fb\u200d\U0001f9bc", "man in motorized wheelchair: Light Skin Tone", []string{"man_in_motorized_wheelchair_Light_Skin_Tone"}, "12.0", false}, {"\U0001f468\U0001f3fe\u200d\U0001f9bc", "man in motorized wheelchair: Medium-Dark Skin Tone", []string{"man_in_motorized_wheelchair_Medium-Dark_Skin_Tone"}, "12.0", false}, {"\U0001f468\U0001f3fc\u200d\U0001f9bc", "man in motorized wheelchair: Medium-Light Skin Tone", []string{"man_in_motorized_wheelchair_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f468\U0001f3fd\u200d\U0001f9bc", "man in motorized wheelchair: Medium Skin Tone", []string{"man_in_motorized_wheelchair_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f468\u200d\U0001f9bc\u200d\u27a1\ufe0f", "man in motorized wheelchair facing right", []string{"man_in_motorized_wheelchair_facing_right"}, "15.1", true}, + {"\U0001f468\U0001f3ff\u200d\U0001f9bc\u200d\u27a1\ufe0f", "man in motorized wheelchair facing right: Dark Skin Tone", []string{"man_in_motorized_wheelchair_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f468\U0001f3fb\u200d\U0001f9bc\u200d\u27a1\ufe0f", "man in motorized wheelchair facing right: Light Skin Tone", []string{"man_in_motorized_wheelchair_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f468\U0001f3fe\u200d\U0001f9bc\u200d\u27a1\ufe0f", "man in motorized wheelchair facing right: Medium-Dark Skin Tone", []string{"man_in_motorized_wheelchair_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f468\U0001f3fc\u200d\U0001f9bc\u200d\u27a1\ufe0f", "man in motorized wheelchair facing right: Medium-Light Skin Tone", []string{"man_in_motorized_wheelchair_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f468\U0001f3fd\u200d\U0001f9bc\u200d\u27a1\ufe0f", "man in motorized wheelchair facing right: Medium Skin Tone", []string{"man_in_motorized_wheelchair_facing_right_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f935\u200d\u2642\ufe0f", "man in tuxedo", []string{"man_in_tuxedo"}, "13.0", true}, {"\U0001f935\U0001f3ff\u200d\u2642\ufe0f", "man in tuxedo: Dark Skin Tone", []string{"man_in_tuxedo_Dark_Skin_Tone"}, "12.0", false}, {"\U0001f935\U0001f3fb\u200d\u2642\ufe0f", "man in tuxedo: Light Skin Tone", []string{"man_in_tuxedo_Light_Skin_Tone"}, "12.0", false}, @@ -1618,6 +1644,12 @@ var GemojiData = Gemoji{ {"\U0001f939\U0001f3fe\u200d\u2642\ufe0f", "man juggling: Medium-Dark Skin Tone", []string{"man_juggling_Medium-Dark_Skin_Tone"}, "12.0", false}, {"\U0001f939\U0001f3fc\u200d\u2642\ufe0f", "man juggling: Medium-Light Skin Tone", []string{"man_juggling_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f939\U0001f3fd\u200d\u2642\ufe0f", "man juggling: Medium Skin Tone", []string{"man_juggling_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f9ce\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man kneeling facing right", []string{"man_kneeling_facing_right"}, "15.1", true}, + {"\U0001f9ce\U0001f3ff\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man kneeling facing right: Dark Skin Tone", []string{"man_kneeling_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f9ce\U0001f3fb\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man kneeling facing right: Light Skin Tone", []string{"man_kneeling_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f9ce\U0001f3fe\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man kneeling facing right: Medium-Dark Skin Tone", []string{"man_kneeling_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f9ce\U0001f3fc\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man kneeling facing right: Medium-Light Skin Tone", []string{"man_kneeling_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f9ce\U0001f3fd\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man kneeling facing right: Medium Skin Tone", []string{"man_kneeling_facing_right_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f468\u200d\U0001f527", "man mechanic", []string{"man_mechanic"}, "", true}, {"\U0001f468\U0001f3ff\u200d\U0001f527", "man mechanic: Dark Skin Tone", []string{"man_mechanic_Dark_Skin_Tone"}, "12.0", false}, {"\U0001f468\U0001f3fb\u200d\U0001f527", "man mechanic: Light Skin Tone", []string{"man_mechanic_Light_Skin_Tone"}, "12.0", false}, @@ -1648,6 +1680,12 @@ var GemojiData = Gemoji{ {"\U0001f93d\U0001f3fe\u200d\u2642\ufe0f", "man playing water polo: Medium-Dark Skin Tone", []string{"man_playing_water_polo_Medium-Dark_Skin_Tone"}, "12.0", false}, {"\U0001f93d\U0001f3fc\u200d\u2642\ufe0f", "man playing water polo: Medium-Light Skin Tone", []string{"man_playing_water_polo_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f93d\U0001f3fd\u200d\u2642\ufe0f", "man playing water polo: Medium Skin Tone", []string{"man_playing_water_polo_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f3c3\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man running facing right", []string{"man_running_facing_right"}, "15.1", true}, + {"\U0001f3c3\U0001f3ff\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man running facing right: Dark Skin Tone", []string{"man_running_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f3c3\U0001f3fb\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man running facing right: Light Skin Tone", []string{"man_running_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f3c3\U0001f3fe\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man running facing right: Medium-Dark Skin Tone", []string{"man_running_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f3c3\U0001f3fc\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man running facing right: Medium-Light Skin Tone", []string{"man_running_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f3c3\U0001f3fd\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man running facing right: Medium Skin Tone", []string{"man_running_facing_right_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f468\u200d\U0001f52c", "man scientist", []string{"man_scientist"}, "", true}, {"\U0001f468\U0001f3ff\u200d\U0001f52c", "man scientist: Dark Skin Tone", []string{"man_scientist_Dark_Skin_Tone"}, "12.0", false}, {"\U0001f468\U0001f3fb\u200d\U0001f52c", "man scientist: Light Skin Tone", []string{"man_scientist_Light_Skin_Tone"}, "12.0", false}, @@ -1684,6 +1722,12 @@ var GemojiData = Gemoji{ {"\U0001f468\U0001f3fe\u200d\U0001f4bb", "man technologist: Medium-Dark Skin Tone", []string{"man_technologist_Medium-Dark_Skin_Tone"}, "12.0", false}, {"\U0001f468\U0001f3fc\u200d\U0001f4bb", "man technologist: Medium-Light Skin Tone", []string{"man_technologist_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f468\U0001f3fd\u200d\U0001f4bb", "man technologist: Medium Skin Tone", []string{"man_technologist_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f6b6\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man walking facing right", []string{"man_walking_facing_right"}, "15.1", true}, + {"\U0001f6b6\U0001f3ff\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man walking facing right: Dark Skin Tone", []string{"man_walking_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f6b6\U0001f3fb\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man walking facing right: Light Skin Tone", []string{"man_walking_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f6b6\U0001f3fe\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man walking facing right: Medium-Dark Skin Tone", []string{"man_walking_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f6b6\U0001f3fc\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man walking facing right: Medium-Light Skin Tone", []string{"man_walking_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f6b6\U0001f3fd\u200d\u2642\ufe0f\u200d\u27a1\ufe0f", "man walking facing right: Medium Skin Tone", []string{"man_walking_facing_right_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f472", "person with skullcap", []string{"man_with_gua_pi_mao"}, "6.0", true}, {"\U0001f472\U0001f3ff", "person with skullcap: Dark Skin Tone", []string{"man_with_gua_pi_mao_Dark_Skin_Tone"}, "12.0", false}, {"\U0001f472\U0001f3fb", "person with skullcap: Light Skin Tone", []string{"man_with_gua_pi_mao_Light_Skin_Tone"}, "12.0", false}, @@ -1708,6 +1752,12 @@ var GemojiData = Gemoji{ {"\U0001f470\U0001f3fe\u200d\u2642\ufe0f", "man with veil: Medium-Dark Skin Tone", []string{"man_with_veil_Medium-Dark_Skin_Tone"}, "12.0", false}, {"\U0001f470\U0001f3fc\u200d\u2642\ufe0f", "man with veil: Medium-Light Skin Tone", []string{"man_with_veil_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f470\U0001f3fd\u200d\u2642\ufe0f", "man with veil: Medium Skin Tone", []string{"man_with_veil_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f468\u200d\U0001f9af\u200d\u27a1\ufe0f", "man with white cane facing right", []string{"man_with_white_cane_facing_right"}, "15.1", true}, + {"\U0001f468\U0001f3ff\u200d\U0001f9af\u200d\u27a1\ufe0f", "man with white cane facing right: Dark Skin Tone", []string{"man_with_white_cane_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f468\U0001f3fb\u200d\U0001f9af\u200d\u27a1\ufe0f", "man with white cane facing right: Light Skin Tone", []string{"man_with_white_cane_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f468\U0001f3fe\u200d\U0001f9af\u200d\u27a1\ufe0f", "man with white cane facing right: Medium-Dark Skin Tone", []string{"man_with_white_cane_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f468\U0001f3fc\u200d\U0001f9af\u200d\u27a1\ufe0f", "man with white cane facing right: Medium-Light Skin Tone", []string{"man_with_white_cane_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f468\U0001f3fd\u200d\U0001f9af\u200d\u27a1\ufe0f", "man with white cane facing right: Medium Skin Tone", []string{"man_with_white_cane_facing_right_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f96d", "mango", []string{"mango"}, "11.0", false}, {"\U0001f45e", "man’s shoe", []string{"mans_shoe", "shoe"}, "6.0", false}, {"\U0001f570\ufe0f", "mantelpiece clock", []string{"mantelpiece_clock"}, "7.0", false}, @@ -1874,12 +1924,12 @@ var GemojiData = Gemoji{ {"\U0001f3b5", "musical note", []string{"musical_note"}, "6.0", false}, {"\U0001f3bc", "musical score", []string{"musical_score"}, "6.0", false}, {"\U0001f507", "muted speaker", []string{"mute"}, "6.0", false}, - {"\U0001f9d1\u200d\U0001f384", "mx claus", []string{"mx_claus"}, "13.0", true}, - {"\U0001f9d1\U0001f3ff\u200d\U0001f384", "mx claus: Dark Skin Tone", []string{"mx_claus_Dark_Skin_Tone"}, "12.0", false}, - {"\U0001f9d1\U0001f3fb\u200d\U0001f384", "mx claus: Light Skin Tone", []string{"mx_claus_Light_Skin_Tone"}, "12.0", false}, - {"\U0001f9d1\U0001f3fe\u200d\U0001f384", "mx claus: Medium-Dark Skin Tone", []string{"mx_claus_Medium-Dark_Skin_Tone"}, "12.0", false}, - {"\U0001f9d1\U0001f3fc\u200d\U0001f384", "mx claus: Medium-Light Skin Tone", []string{"mx_claus_Medium-Light_Skin_Tone"}, "12.0", false}, - {"\U0001f9d1\U0001f3fd\u200d\U0001f384", "mx claus: Medium Skin Tone", []string{"mx_claus_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\u200d\U0001f384", "Mx Claus", []string{"mx_claus"}, "13.0", true}, + {"\U0001f9d1\U0001f3ff\u200d\U0001f384", "Mx Claus: Dark Skin Tone", []string{"mx_claus_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\U0001f3fb\u200d\U0001f384", "Mx Claus: Light Skin Tone", []string{"mx_claus_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\U0001f3fe\u200d\U0001f384", "Mx Claus: Medium-Dark Skin Tone", []string{"mx_claus_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\U0001f3fc\u200d\U0001f384", "Mx Claus: Medium-Light Skin Tone", []string{"mx_claus_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\U0001f3fd\u200d\U0001f384", "Mx Claus: Medium Skin Tone", []string{"mx_claus_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f1f2\U0001f1f2", "flag: Myanmar (Burma)", []string{"myanmar"}, "6.0", false}, {"\U0001f485", "nail polish", []string{"nail_care"}, "6.0", true}, {"\U0001f485\U0001f3ff", "nail polish: Dark Skin Tone", []string{"nail_care_Dark_Skin_Tone"}, "12.0", false}, @@ -2140,24 +2190,54 @@ var GemojiData = Gemoji{ {"\U0001f9d1\U0001f3fe\u200d\U0001f9bd", "person in manual wheelchair: Medium-Dark Skin Tone", []string{"person_in_manual_wheelchair_Medium-Dark_Skin_Tone"}, "12.0", false}, {"\U0001f9d1\U0001f3fc\u200d\U0001f9bd", "person in manual wheelchair: Medium-Light Skin Tone", []string{"person_in_manual_wheelchair_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f9d1\U0001f3fd\u200d\U0001f9bd", "person in manual wheelchair: Medium Skin Tone", []string{"person_in_manual_wheelchair_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\u200d\U0001f9bd\u200d\u27a1\ufe0f", "person in manual wheelchair facing right", []string{"person_in_manual_wheelchair_facing_right"}, "15.1", true}, + {"\U0001f9d1\U0001f3ff\u200d\U0001f9bd\u200d\u27a1\ufe0f", "person in manual wheelchair facing right: Dark Skin Tone", []string{"person_in_manual_wheelchair_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\U0001f3fb\u200d\U0001f9bd\u200d\u27a1\ufe0f", "person in manual wheelchair facing right: Light Skin Tone", []string{"person_in_manual_wheelchair_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\U0001f3fe\u200d\U0001f9bd\u200d\u27a1\ufe0f", "person in manual wheelchair facing right: Medium-Dark Skin Tone", []string{"person_in_manual_wheelchair_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\U0001f3fc\u200d\U0001f9bd\u200d\u27a1\ufe0f", "person in manual wheelchair facing right: Medium-Light Skin Tone", []string{"person_in_manual_wheelchair_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\U0001f3fd\u200d\U0001f9bd\u200d\u27a1\ufe0f", "person in manual wheelchair facing right: Medium Skin Tone", []string{"person_in_manual_wheelchair_facing_right_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f9d1\u200d\U0001f9bc", "person in motorized wheelchair", []string{"person_in_motorized_wheelchair"}, "12.1", true}, {"\U0001f9d1\U0001f3ff\u200d\U0001f9bc", "person in motorized wheelchair: Dark Skin Tone", []string{"person_in_motorized_wheelchair_Dark_Skin_Tone"}, "12.0", false}, {"\U0001f9d1\U0001f3fb\u200d\U0001f9bc", "person in motorized wheelchair: Light Skin Tone", []string{"person_in_motorized_wheelchair_Light_Skin_Tone"}, "12.0", false}, {"\U0001f9d1\U0001f3fe\u200d\U0001f9bc", "person in motorized wheelchair: Medium-Dark Skin Tone", []string{"person_in_motorized_wheelchair_Medium-Dark_Skin_Tone"}, "12.0", false}, {"\U0001f9d1\U0001f3fc\u200d\U0001f9bc", "person in motorized wheelchair: Medium-Light Skin Tone", []string{"person_in_motorized_wheelchair_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f9d1\U0001f3fd\u200d\U0001f9bc", "person in motorized wheelchair: Medium Skin Tone", []string{"person_in_motorized_wheelchair_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\u200d\U0001f9bc\u200d\u27a1\ufe0f", "person in motorized wheelchair facing right", []string{"person_in_motorized_wheelchair_facing_right"}, "15.1", true}, + {"\U0001f9d1\U0001f3ff\u200d\U0001f9bc\u200d\u27a1\ufe0f", "person in motorized wheelchair facing right: Dark Skin Tone", []string{"person_in_motorized_wheelchair_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\U0001f3fb\u200d\U0001f9bc\u200d\u27a1\ufe0f", "person in motorized wheelchair facing right: Light Skin Tone", []string{"person_in_motorized_wheelchair_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\U0001f3fe\u200d\U0001f9bc\u200d\u27a1\ufe0f", "person in motorized wheelchair facing right: Medium-Dark Skin Tone", []string{"person_in_motorized_wheelchair_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\U0001f3fc\u200d\U0001f9bc\u200d\u27a1\ufe0f", "person in motorized wheelchair facing right: Medium-Light Skin Tone", []string{"person_in_motorized_wheelchair_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\U0001f3fd\u200d\U0001f9bc\u200d\u27a1\ufe0f", "person in motorized wheelchair facing right: Medium Skin Tone", []string{"person_in_motorized_wheelchair_facing_right_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f935", "person in tuxedo", []string{"person_in_tuxedo"}, "9.0", true}, {"\U0001f935\U0001f3ff", "person in tuxedo: Dark Skin Tone", []string{"person_in_tuxedo_Dark_Skin_Tone"}, "12.0", false}, {"\U0001f935\U0001f3fb", "person in tuxedo: Light Skin Tone", []string{"person_in_tuxedo_Light_Skin_Tone"}, "12.0", false}, {"\U0001f935\U0001f3fe", "person in tuxedo: Medium-Dark Skin Tone", []string{"person_in_tuxedo_Medium-Dark_Skin_Tone"}, "12.0", false}, {"\U0001f935\U0001f3fc", "person in tuxedo: Medium-Light Skin Tone", []string{"person_in_tuxedo_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f935\U0001f3fd", "person in tuxedo: Medium Skin Tone", []string{"person_in_tuxedo_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f9ce\u200d\u27a1\ufe0f", "person kneeling facing right", []string{"person_kneeling_facing_right"}, "15.1", true}, + {"\U0001f9ce\U0001f3ff\u200d\u27a1\ufe0f", "person kneeling facing right: Dark Skin Tone", []string{"person_kneeling_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f9ce\U0001f3fb\u200d\u27a1\ufe0f", "person kneeling facing right: Light Skin Tone", []string{"person_kneeling_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f9ce\U0001f3fe\u200d\u27a1\ufe0f", "person kneeling facing right: Medium-Dark Skin Tone", []string{"person_kneeling_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f9ce\U0001f3fc\u200d\u27a1\ufe0f", "person kneeling facing right: Medium-Light Skin Tone", []string{"person_kneeling_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f9ce\U0001f3fd\u200d\u27a1\ufe0f", "person kneeling facing right: Medium Skin Tone", []string{"person_kneeling_facing_right_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f9d1\u200d\U0001f9b0", "person: red hair", []string{"person_red_hair"}, "12.1", true}, {"\U0001f9d1\U0001f3ff\u200d\U0001f9b0", "person: red hair: Dark Skin Tone", []string{"person_red_hair_Dark_Skin_Tone"}, "12.0", false}, {"\U0001f9d1\U0001f3fb\u200d\U0001f9b0", "person: red hair: Light Skin Tone", []string{"person_red_hair_Light_Skin_Tone"}, "12.0", false}, {"\U0001f9d1\U0001f3fe\u200d\U0001f9b0", "person: red hair: Medium-Dark Skin Tone", []string{"person_red_hair_Medium-Dark_Skin_Tone"}, "12.0", false}, {"\U0001f9d1\U0001f3fc\u200d\U0001f9b0", "person: red hair: Medium-Light Skin Tone", []string{"person_red_hair_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f9d1\U0001f3fd\u200d\U0001f9b0", "person: red hair: Medium Skin Tone", []string{"person_red_hair_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f3c3\u200d\u27a1\ufe0f", "person running facing right", []string{"person_running_facing_right"}, "15.1", true}, + {"\U0001f3c3\U0001f3ff\u200d\u27a1\ufe0f", "person running facing right: Dark Skin Tone", []string{"person_running_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f3c3\U0001f3fb\u200d\u27a1\ufe0f", "person running facing right: Light Skin Tone", []string{"person_running_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f3c3\U0001f3fe\u200d\u27a1\ufe0f", "person running facing right: Medium-Dark Skin Tone", []string{"person_running_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f3c3\U0001f3fc\u200d\u27a1\ufe0f", "person running facing right: Medium-Light Skin Tone", []string{"person_running_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f3c3\U0001f3fd\u200d\u27a1\ufe0f", "person running facing right: Medium Skin Tone", []string{"person_running_facing_right_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f6b6\u200d\u27a1\ufe0f", "person walking facing right", []string{"person_walking_facing_right"}, "15.1", true}, + {"\U0001f6b6\U0001f3ff\u200d\u27a1\ufe0f", "person walking facing right: Dark Skin Tone", []string{"person_walking_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f6b6\U0001f3fb\u200d\u27a1\ufe0f", "person walking facing right: Light Skin Tone", []string{"person_walking_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f6b6\U0001f3fe\u200d\u27a1\ufe0f", "person walking facing right: Medium-Dark Skin Tone", []string{"person_walking_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f6b6\U0001f3fc\u200d\u27a1\ufe0f", "person walking facing right: Medium-Light Skin Tone", []string{"person_walking_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f6b6\U0001f3fd\u200d\u27a1\ufe0f", "person walking facing right: Medium Skin Tone", []string{"person_walking_facing_right_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f9d1\u200d\U0001f9b3", "person: white hair", []string{"person_white_hair"}, "12.1", true}, {"\U0001f9d1\U0001f3ff\u200d\U0001f9b3", "person: white hair: Dark Skin Tone", []string{"person_white_hair_Dark_Skin_Tone"}, "12.0", false}, {"\U0001f9d1\U0001f3fb\u200d\U0001f9b3", "person: white hair: Light Skin Tone", []string{"person_white_hair_Light_Skin_Tone"}, "12.0", false}, @@ -2188,9 +2268,16 @@ var GemojiData = Gemoji{ {"\U0001f470\U0001f3fe", "person with veil: Medium-Dark Skin Tone", []string{"person_with_veil_Medium-Dark_Skin_Tone"}, "12.0", false}, {"\U0001f470\U0001f3fc", "person with veil: Medium-Light Skin Tone", []string{"person_with_veil_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f470\U0001f3fd", "person with veil: Medium Skin Tone", []string{"person_with_veil_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\u200d\U0001f9af\u200d\u27a1\ufe0f", "person with white cane facing right", []string{"person_with_white_cane_facing_right"}, "15.1", true}, + {"\U0001f9d1\U0001f3ff\u200d\U0001f9af\u200d\u27a1\ufe0f", "person with white cane facing right: Dark Skin Tone", []string{"person_with_white_cane_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\U0001f3fb\u200d\U0001f9af\u200d\u27a1\ufe0f", "person with white cane facing right: Light Skin Tone", []string{"person_with_white_cane_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\U0001f3fe\u200d\U0001f9af\u200d\u27a1\ufe0f", "person with white cane facing right: Medium-Dark Skin Tone", []string{"person_with_white_cane_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\U0001f3fc\u200d\U0001f9af\u200d\u27a1\ufe0f", "person with white cane facing right: Medium-Light Skin Tone", []string{"person_with_white_cane_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f9d1\U0001f3fd\u200d\U0001f9af\u200d\u27a1\ufe0f", "person with white cane facing right: Medium Skin Tone", []string{"person_with_white_cane_facing_right_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f1f5\U0001f1ea", "flag: Peru", []string{"peru"}, "6.0", false}, {"\U0001f9eb", "petri dish", []string{"petri_dish"}, "11.0", false}, {"\U0001f1f5\U0001f1ed", "flag: Philippines", []string{"philippines"}, "6.0", false}, + {"\U0001f426\u200d\U0001f525", "phoenix", []string{"phoenix"}, "15.1", false}, {"\u260e\ufe0f", "telephone", []string{"phone", "telephone"}, "", false}, {"\u26cf\ufe0f", "pick", []string{"pick"}, "5.2", false}, {"\U0001f6fb", "pickup truck", []string{"pickup_truck"}, "13.0", false}, @@ -2480,6 +2567,7 @@ var GemojiData = Gemoji{ {"\U0001f6fc", "roller skate", []string{"roller_skate"}, "13.0", false}, {"\U0001f1f7\U0001f1f4", "flag: Romania", []string{"romania"}, "6.0", false}, {"\U0001f413", "rooster", []string{"rooster"}, "6.0", false}, + {"\U0001fadc", "root vegetable", []string{"root_vegetable"}, "16.0", false}, {"\U0001f339", "rose", []string{"rose"}, "6.0", false}, {"\U0001f3f5\ufe0f", "rosette", []string{"rosette"}, "7.0", false}, {"\U0001f6a8", "police car light", []string{"rotating_light"}, "6.0", false}, @@ -2613,6 +2701,7 @@ var GemojiData = Gemoji{ {"\U0001f6cd\ufe0f", "shopping bags", []string{"shopping"}, "7.0", false}, {"\U0001f6d2", "shopping cart", []string{"shopping_cart"}, "9.0", false}, {"\U0001fa73", "shorts", []string{"shorts"}, "12.0", false}, + {"\U0001fa8f", "shovel", []string{"shovel"}, "16.0", false}, {"\U0001f6bf", "shower", []string{"shower"}, "6.0", false}, {"\U0001f990", "shrimp", []string{"shrimp"}, "9.0", false}, {"\U0001f937", "person shrugging", []string{"shrug"}, "11.0", true}, @@ -2711,6 +2800,7 @@ var GemojiData = Gemoji{ {"\U0001f578\ufe0f", "spider web", []string{"spider_web"}, "7.0", false}, {"\U0001f5d3\ufe0f", "spiral calendar", []string{"spiral_calendar"}, "7.0", false}, {"\U0001f5d2\ufe0f", "spiral notepad", []string{"spiral_notepad"}, "7.0", false}, + {"\U0001fadf", "splatter", []string{"splatter"}, "16.0", false}, {"\U0001f9fd", "sponge", []string{"sponge"}, "11.0", false}, {"\U0001f944", "spoon", []string{"spoon"}, "9.0", false}, {"\U0001f991", "squid", []string{"squid"}, "9.0", false}, @@ -2945,7 +3035,7 @@ var GemojiData = Gemoji{ {"\U0001f51d", "TOP arrow", []string{"top"}, "6.0", false}, {"\U0001f3a9", "top hat", []string{"tophat"}, "6.0", false}, {"\U0001f32a\ufe0f", "tornado", []string{"tornado"}, "7.0", false}, - {"\U0001f1f9\U0001f1f7", "flag: Turkey", []string{"tr"}, "8.0", false}, + {"\U0001f1f9\U0001f1f7", "flag: Türkiye", []string{"tr"}, "8.0", false}, {"\U0001f5b2\ufe0f", "trackball", []string{"trackball"}, "7.0", false}, {"\U0001f69c", "tractor", []string{"tractor"}, "6.0", false}, {"\U0001f6a5", "horizontal traffic light", []string{"traffic_light"}, "6.0", false}, @@ -3247,12 +3337,24 @@ var GemojiData = Gemoji{ {"\U0001f469\U0001f3fe\u200d\U0001f9bd", "woman in manual wheelchair: Medium-Dark Skin Tone", []string{"woman_in_manual_wheelchair_Medium-Dark_Skin_Tone"}, "12.0", false}, {"\U0001f469\U0001f3fc\u200d\U0001f9bd", "woman in manual wheelchair: Medium-Light Skin Tone", []string{"woman_in_manual_wheelchair_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f469\U0001f3fd\u200d\U0001f9bd", "woman in manual wheelchair: Medium Skin Tone", []string{"woman_in_manual_wheelchair_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f469\u200d\U0001f9bd\u200d\u27a1\ufe0f", "woman in manual wheelchair facing right", []string{"woman_in_manual_wheelchair_facing_right"}, "15.1", true}, + {"\U0001f469\U0001f3ff\u200d\U0001f9bd\u200d\u27a1\ufe0f", "woman in manual wheelchair facing right: Dark Skin Tone", []string{"woman_in_manual_wheelchair_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f469\U0001f3fb\u200d\U0001f9bd\u200d\u27a1\ufe0f", "woman in manual wheelchair facing right: Light Skin Tone", []string{"woman_in_manual_wheelchair_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f469\U0001f3fe\u200d\U0001f9bd\u200d\u27a1\ufe0f", "woman in manual wheelchair facing right: Medium-Dark Skin Tone", []string{"woman_in_manual_wheelchair_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f469\U0001f3fc\u200d\U0001f9bd\u200d\u27a1\ufe0f", "woman in manual wheelchair facing right: Medium-Light Skin Tone", []string{"woman_in_manual_wheelchair_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f469\U0001f3fd\u200d\U0001f9bd\u200d\u27a1\ufe0f", "woman in manual wheelchair facing right: Medium Skin Tone", []string{"woman_in_manual_wheelchair_facing_right_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f469\u200d\U0001f9bc", "woman in motorized wheelchair", []string{"woman_in_motorized_wheelchair"}, "12.0", true}, {"\U0001f469\U0001f3ff\u200d\U0001f9bc", "woman in motorized wheelchair: Dark Skin Tone", []string{"woman_in_motorized_wheelchair_Dark_Skin_Tone"}, "12.0", false}, {"\U0001f469\U0001f3fb\u200d\U0001f9bc", "woman in motorized wheelchair: Light Skin Tone", []string{"woman_in_motorized_wheelchair_Light_Skin_Tone"}, "12.0", false}, {"\U0001f469\U0001f3fe\u200d\U0001f9bc", "woman in motorized wheelchair: Medium-Dark Skin Tone", []string{"woman_in_motorized_wheelchair_Medium-Dark_Skin_Tone"}, "12.0", false}, {"\U0001f469\U0001f3fc\u200d\U0001f9bc", "woman in motorized wheelchair: Medium-Light Skin Tone", []string{"woman_in_motorized_wheelchair_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f469\U0001f3fd\u200d\U0001f9bc", "woman in motorized wheelchair: Medium Skin Tone", []string{"woman_in_motorized_wheelchair_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f469\u200d\U0001f9bc\u200d\u27a1\ufe0f", "woman in motorized wheelchair facing right", []string{"woman_in_motorized_wheelchair_facing_right"}, "15.1", true}, + {"\U0001f469\U0001f3ff\u200d\U0001f9bc\u200d\u27a1\ufe0f", "woman in motorized wheelchair facing right: Dark Skin Tone", []string{"woman_in_motorized_wheelchair_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f469\U0001f3fb\u200d\U0001f9bc\u200d\u27a1\ufe0f", "woman in motorized wheelchair facing right: Light Skin Tone", []string{"woman_in_motorized_wheelchair_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f469\U0001f3fe\u200d\U0001f9bc\u200d\u27a1\ufe0f", "woman in motorized wheelchair facing right: Medium-Dark Skin Tone", []string{"woman_in_motorized_wheelchair_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f469\U0001f3fc\u200d\U0001f9bc\u200d\u27a1\ufe0f", "woman in motorized wheelchair facing right: Medium-Light Skin Tone", []string{"woman_in_motorized_wheelchair_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f469\U0001f3fd\u200d\U0001f9bc\u200d\u27a1\ufe0f", "woman in motorized wheelchair facing right: Medium Skin Tone", []string{"woman_in_motorized_wheelchair_facing_right_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f935\u200d\u2640\ufe0f", "woman in tuxedo", []string{"woman_in_tuxedo"}, "13.0", true}, {"\U0001f935\U0001f3ff\u200d\u2640\ufe0f", "woman in tuxedo: Dark Skin Tone", []string{"woman_in_tuxedo_Dark_Skin_Tone"}, "12.0", false}, {"\U0001f935\U0001f3fb\u200d\u2640\ufe0f", "woman in tuxedo: Light Skin Tone", []string{"woman_in_tuxedo_Light_Skin_Tone"}, "12.0", false}, @@ -3271,6 +3373,12 @@ var GemojiData = Gemoji{ {"\U0001f939\U0001f3fe\u200d\u2640\ufe0f", "woman juggling: Medium-Dark Skin Tone", []string{"woman_juggling_Medium-Dark_Skin_Tone"}, "12.0", false}, {"\U0001f939\U0001f3fc\u200d\u2640\ufe0f", "woman juggling: Medium-Light Skin Tone", []string{"woman_juggling_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f939\U0001f3fd\u200d\u2640\ufe0f", "woman juggling: Medium Skin Tone", []string{"woman_juggling_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f9ce\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman kneeling facing right", []string{"woman_kneeling_facing_right"}, "15.1", true}, + {"\U0001f9ce\U0001f3ff\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman kneeling facing right: Dark Skin Tone", []string{"woman_kneeling_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f9ce\U0001f3fb\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman kneeling facing right: Light Skin Tone", []string{"woman_kneeling_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f9ce\U0001f3fe\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman kneeling facing right: Medium-Dark Skin Tone", []string{"woman_kneeling_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f9ce\U0001f3fc\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman kneeling facing right: Medium-Light Skin Tone", []string{"woman_kneeling_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f9ce\U0001f3fd\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman kneeling facing right: Medium Skin Tone", []string{"woman_kneeling_facing_right_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f469\u200d\U0001f527", "woman mechanic", []string{"woman_mechanic"}, "", true}, {"\U0001f469\U0001f3ff\u200d\U0001f527", "woman mechanic: Dark Skin Tone", []string{"woman_mechanic_Dark_Skin_Tone"}, "12.0", false}, {"\U0001f469\U0001f3fb\u200d\U0001f527", "woman mechanic: Light Skin Tone", []string{"woman_mechanic_Light_Skin_Tone"}, "12.0", false}, @@ -3301,6 +3409,12 @@ var GemojiData = Gemoji{ {"\U0001f93d\U0001f3fe\u200d\u2640\ufe0f", "woman playing water polo: Medium-Dark Skin Tone", []string{"woman_playing_water_polo_Medium-Dark_Skin_Tone"}, "12.0", false}, {"\U0001f93d\U0001f3fc\u200d\u2640\ufe0f", "woman playing water polo: Medium-Light Skin Tone", []string{"woman_playing_water_polo_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f93d\U0001f3fd\u200d\u2640\ufe0f", "woman playing water polo: Medium Skin Tone", []string{"woman_playing_water_polo_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f3c3\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman running facing right", []string{"woman_running_facing_right"}, "15.1", true}, + {"\U0001f3c3\U0001f3ff\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman running facing right: Dark Skin Tone", []string{"woman_running_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f3c3\U0001f3fb\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman running facing right: Light Skin Tone", []string{"woman_running_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f3c3\U0001f3fe\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman running facing right: Medium-Dark Skin Tone", []string{"woman_running_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f3c3\U0001f3fc\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman running facing right: Medium-Light Skin Tone", []string{"woman_running_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f3c3\U0001f3fd\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman running facing right: Medium Skin Tone", []string{"woman_running_facing_right_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f469\u200d\U0001f52c", "woman scientist", []string{"woman_scientist"}, "", true}, {"\U0001f469\U0001f3ff\u200d\U0001f52c", "woman scientist: Dark Skin Tone", []string{"woman_scientist_Dark_Skin_Tone"}, "12.0", false}, {"\U0001f469\U0001f3fb\u200d\U0001f52c", "woman scientist: Light Skin Tone", []string{"woman_scientist_Light_Skin_Tone"}, "12.0", false}, @@ -3337,6 +3451,12 @@ var GemojiData = Gemoji{ {"\U0001f469\U0001f3fe\u200d\U0001f4bb", "woman technologist: Medium-Dark Skin Tone", []string{"woman_technologist_Medium-Dark_Skin_Tone"}, "12.0", false}, {"\U0001f469\U0001f3fc\u200d\U0001f4bb", "woman technologist: Medium-Light Skin Tone", []string{"woman_technologist_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f469\U0001f3fd\u200d\U0001f4bb", "woman technologist: Medium Skin Tone", []string{"woman_technologist_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f6b6\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman walking facing right", []string{"woman_walking_facing_right"}, "15.1", true}, + {"\U0001f6b6\U0001f3ff\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman walking facing right: Dark Skin Tone", []string{"woman_walking_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f6b6\U0001f3fb\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman walking facing right: Light Skin Tone", []string{"woman_walking_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f6b6\U0001f3fe\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman walking facing right: Medium-Dark Skin Tone", []string{"woman_walking_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f6b6\U0001f3fc\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman walking facing right: Medium-Light Skin Tone", []string{"woman_walking_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f6b6\U0001f3fd\u200d\u2640\ufe0f\u200d\u27a1\ufe0f", "woman walking facing right: Medium Skin Tone", []string{"woman_walking_facing_right_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f9d5", "woman with headscarf", []string{"woman_with_headscarf"}, "11.0", true}, {"\U0001f9d5\U0001f3ff", "woman with headscarf: Dark Skin Tone", []string{"woman_with_headscarf_Dark_Skin_Tone"}, "12.0", false}, {"\U0001f9d5\U0001f3fb", "woman with headscarf: Light Skin Tone", []string{"woman_with_headscarf_Light_Skin_Tone"}, "12.0", false}, @@ -3361,6 +3481,12 @@ var GemojiData = Gemoji{ {"\U0001f470\U0001f3fe\u200d\u2640\ufe0f", "woman with veil: Medium-Dark Skin Tone", []string{"woman_with_veil_Medium-Dark_Skin_Tone"}, "12.0", false}, {"\U0001f470\U0001f3fc\u200d\u2640\ufe0f", "woman with veil: Medium-Light Skin Tone", []string{"woman_with_veil_Medium-Light_Skin_Tone"}, "12.0", false}, {"\U0001f470\U0001f3fd\u200d\u2640\ufe0f", "woman with veil: Medium Skin Tone", []string{"woman_with_veil_Medium_Skin_Tone"}, "12.0", false}, + {"\U0001f469\u200d\U0001f9af\u200d\u27a1\ufe0f", "woman with white cane facing right", []string{"woman_with_white_cane_facing_right"}, "15.1", true}, + {"\U0001f469\U0001f3ff\u200d\U0001f9af\u200d\u27a1\ufe0f", "woman with white cane facing right: Dark Skin Tone", []string{"woman_with_white_cane_facing_right_Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f469\U0001f3fb\u200d\U0001f9af\u200d\u27a1\ufe0f", "woman with white cane facing right: Light Skin Tone", []string{"woman_with_white_cane_facing_right_Light_Skin_Tone"}, "12.0", false}, + {"\U0001f469\U0001f3fe\u200d\U0001f9af\u200d\u27a1\ufe0f", "woman with white cane facing right: Medium-Dark Skin Tone", []string{"woman_with_white_cane_facing_right_Medium-Dark_Skin_Tone"}, "12.0", false}, + {"\U0001f469\U0001f3fc\u200d\U0001f9af\u200d\u27a1\ufe0f", "woman with white cane facing right: Medium-Light Skin Tone", []string{"woman_with_white_cane_facing_right_Medium-Light_Skin_Tone"}, "12.0", false}, + {"\U0001f469\U0001f3fd\u200d\U0001f9af\u200d\u27a1\ufe0f", "woman with white cane facing right: Medium Skin Tone", []string{"woman_with_white_cane_facing_right_Medium_Skin_Tone"}, "12.0", false}, {"\U0001f45a", "woman’s clothes", []string{"womans_clothes"}, "6.0", false}, {"\U0001f452", "woman’s hat", []string{"womans_hat"}, "6.0", false}, {"\U0001f93c\u200d\u2640\ufe0f", "women wrestling", []string{"women_wrestling"}, "9.0", false}, diff --git a/modules/emoji/emoji_test.go b/modules/emoji/emoji_test.go index fbf80fe41aa..607299cdc10 100644 --- a/modules/emoji/emoji_test.go +++ b/modules/emoji/emoji_test.go @@ -7,14 +7,13 @@ package emoji import ( "testing" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "github.com/stretchr/testify/assert" ) -func TestDumpInfo(t *testing.T) { - t.Logf("codes: %d", len(codeMap)) - t.Logf("aliases: %d", len(aliasMap)) -} - func TestLookup(t *testing.T) { a := FromCode("\U0001f37a") b := FromCode("🍺") @@ -24,7 +23,6 @@ func TestLookup(t *testing.T) { assert.Equal(t, a, b) assert.Equal(t, b, c) assert.Equal(t, c, d) - assert.Equal(t, a, d) m := FromCode("\U0001f44d") n := FromAlias(":thumbsup:") @@ -32,7 +30,20 @@ func TestLookup(t *testing.T) { assert.Equal(t, m, n) assert.Equal(t, m, o) - assert.Equal(t, n, o) + + defer test.MockVariableValue(&setting.UI.EnabledEmojisSet, container.SetOf("thumbsup"))() + defer globalVarsStore.Store(nil) + globalVarsStore.Store(nil) + a = FromCode("\U0001f37a") + c = FromAlias(":beer:") + m = FromCode("\U0001f44d") + n = FromAlias(":thumbsup:") + o = FromAlias("+1") + assert.Nil(t, a) + assert.Nil(t, c) + assert.NotNil(t, m) + assert.NotNil(t, n) + assert.Nil(t, o) } func TestReplacers(t *testing.T) { diff --git a/modules/eventsource/manager_run.go b/modules/eventsource/manager_run.go index f66dc78c7e4..4a42224ddac 100644 --- a/modules/eventsource/manager_run.go +++ b/modules/eventsource/manager_run.go @@ -9,6 +9,7 @@ import ( activities_model "code.gitea.io/gitea/models/activities" issues_model "code.gitea.io/gitea/models/issues" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" @@ -91,7 +92,13 @@ loop: } for _, userStopwatches := range usersStopwatches { - apiSWs, err := convert.ToStopWatches(ctx, userStopwatches.StopWatches) + u, err := user_model.GetUserByID(ctx, userStopwatches.UserID) + if err != nil { + log.Error("Unable to get user %d: %v", userStopwatches.UserID, err) + continue + } + + apiSWs, err := convert.ToStopWatches(ctx, u, userStopwatches.StopWatches) if err != nil { if !issues_model.IsErrIssueNotExist(err) { log.Error("Unable to APIFormat stopwatches: %v", err) diff --git a/modules/fileicon/material.go b/modules/fileicon/material.go index 5361592d8a3..b8ee13cd8cb 100644 --- a/modules/fileicon/material.go +++ b/modules/fileicon/material.go @@ -76,7 +76,7 @@ func (m *MaterialIconProvider) renderFileIconSVG(p *RenderedIconPool, name, svg, if p.IconSVGs[svgID] == "" { p.IconSVGs[svgID] = svgHTML } - return template.HTML(``) + return template.HTML(``) } func (m *MaterialIconProvider) EntryIconHTML(p *RenderedIconPool, entry *EntryInfo) template.HTML { diff --git a/modules/fileicon/render.go b/modules/fileicon/render.go index 8ed86b9ac0e..5bf2a3a02ec 100644 --- a/modules/fileicon/render.go +++ b/modules/fileicon/render.go @@ -25,7 +25,7 @@ func (p *RenderedIconPool) RenderToHTML() template.HTML { return "" } sb := &strings.Builder{} - sb.WriteString(`
`) + sb.WriteString(`
`) for _, icon := range p.IconSVGs { sb.WriteString(string(icon)) } @@ -34,7 +34,13 @@ func (p *RenderedIconPool) RenderToHTML() template.HTML { } func RenderEntryIconHTML(renderedIconPool *RenderedIconPool, entry *EntryInfo) template.HTML { - if setting.UI.FileIconTheme == "material" { + // Use folder theme for directories and symlinks to directories + theme := setting.UI.FileIconTheme + if entry.EntryMode.IsDir() || (entry.EntryMode.IsLink() && entry.SymlinkToMode.IsDir()) { + theme = setting.UI.FolderIconTheme + } + + if theme == "material" { return DefaultMaterialIconProvider().EntryIconHTML(renderedIconPool, entry) } return BasicEntryIconHTML(entry) diff --git a/modules/fileicon/render_test.go b/modules/fileicon/render_test.go new file mode 100644 index 00000000000..d9998f3f4c8 --- /dev/null +++ b/modules/fileicon/render_test.go @@ -0,0 +1,75 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package fileicon_test + +import ( + "testing" + + "code.gitea.io/gitea/modules/fileicon" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + + "github.com/stretchr/testify/assert" +) + +func TestRenderEntryIconHTML_WithDifferentThemes(t *testing.T) { + // Test that folder icons use the folder theme + t.Run("FolderUsesBasicTheme", func(t *testing.T) { + defer test.MockVariableValue(&setting.UI.FileIconTheme, "material")() + defer test.MockVariableValue(&setting.UI.FolderIconTheme, "basic")() + + folderEntry := &fileicon.EntryInfo{ + BaseName: "testfolder", + EntryMode: git.EntryModeTree, + } + + html := fileicon.RenderEntryIconHTML(nil, folderEntry) + // Basic theme renders octicon classes + assert.Contains(t, string(html), "octicon-file-directory-fill") + }) + + t.Run("FileUsesMaterialTheme", func(t *testing.T) { + defer test.MockVariableValue(&setting.UI.FileIconTheme, "material")() + defer test.MockVariableValue(&setting.UI.FolderIconTheme, "basic")() + + fileEntry := &fileicon.EntryInfo{ + BaseName: "test.js", + EntryMode: git.EntryModeBlob, + } + + html := fileicon.RenderEntryIconHTML(nil, fileEntry) + // Material theme for files renders material icons + assert.Contains(t, string(html), "svg-mfi-") + }) + + t.Run("SymlinkToFolderUsesBasicTheme", func(t *testing.T) { + defer test.MockVariableValue(&setting.UI.FileIconTheme, "material")() + defer test.MockVariableValue(&setting.UI.FolderIconTheme, "basic")() + + symlinkEntry := &fileicon.EntryInfo{ + BaseName: "link", + EntryMode: git.EntryModeSymlink, + SymlinkToMode: git.EntryModeTree, + } + + html := fileicon.RenderEntryIconHTML(nil, symlinkEntry) + // Symlinks to folders should use folder theme + assert.Contains(t, string(html), "octicon-file-directory-symlink") + }) + + t.Run("BothMaterialTheme", func(t *testing.T) { + defer test.MockVariableValue(&setting.UI.FileIconTheme, "material")() + defer test.MockVariableValue(&setting.UI.FolderIconTheme, "material")() + + folderEntry := &fileicon.EntryInfo{ + BaseName: "testfolder", + EntryMode: git.EntryModeTree, + } + + html := fileicon.RenderEntryIconHTML(nil, folderEntry) + // Material theme for folders renders material folder icons + assert.Contains(t, string(html), "svg-mfi-") + }) +} diff --git a/modules/git/attribute/attribute.go b/modules/git/attribute/attribute.go index 9c01cb339e0..8719369df8f 100644 --- a/modules/git/attribute/attribute.go +++ b/modules/git/attribute/attribute.go @@ -96,8 +96,8 @@ func (attrs *Attributes) GetGitlabLanguage() optional.Option[string] { // 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]) + if before, _, ok := strings.Cut(raw, "?"); ok { + return optional.Some(before) } } return attrStr diff --git a/modules/git/attribute/batch.go b/modules/git/attribute/batch.go index 9f805d55c51..b1e6387ade7 100644 --- a/modules/git/attribute/batch.go +++ b/modules/git/attribute/batch.go @@ -7,7 +7,7 @@ import ( "bytes" "context" "fmt" - "os" + "io" "path/filepath" "time" @@ -20,7 +20,7 @@ import ( type BatchChecker struct { attributesNum int repo *git.Repository - stdinWriter *os.File + stdinWriter io.WriteCloser stdOut *nulSeparatedAttributeWriter ctx context.Context cancel context.CancelFunc @@ -60,10 +60,7 @@ func NewBatchChecker(repo *git.Repository, treeish string, attributes []string) }, } - stdinReader, stdinWriter, err := os.Pipe() - if err != nil { - return nil, err - } + stdinWriter, stdinWriterClose := cmd.MakeStdinPipe() checker.stdinWriter = stdinWriter lw := new(nulSeparatedAttributeWriter) @@ -71,24 +68,19 @@ func NewBatchChecker(repo *git.Repository, treeish string, attributes []string) lw.closed = make(chan struct{}) checker.stdOut = lw - go func() { - defer func() { - _ = stdinReader.Close() - _ = lw.Close() - }() - stdErr := new(bytes.Buffer) - err := cmd.Run(ctx, &gitcmd.RunOpts{ - Env: envs, - Dir: repo.Path, - Stdin: stdinReader, - Stdout: lw, - Stderr: stdErr, - }) + cmd.WithEnv(envs). + WithDir(repo.Path). + WithStdoutCopy(lw) - if err != nil && !git.IsErrCanceledOrKilled(err) { + go func() { + defer stdinWriterClose() + defer checker.cancel() + defer lw.Close() + + err := cmd.RunWithStderr(ctx) + if err != nil && !gitcmd.IsErrorCanceledOrKilled(err) { log.Error("Attribute checker for commit %s exits with error: %v", treeish, err) } - checker.cancel() }() return checker, nil diff --git a/modules/git/attribute/checker.go b/modules/git/attribute/checker.go index 4b313adf377..3eea31e813c 100644 --- a/modules/git/attribute/checker.go +++ b/modules/git/attribute/checker.go @@ -68,19 +68,14 @@ func CheckAttributes(ctx context.Context, gitRepo *git.Repository, treeish strin } defer cancel() - stdOut := new(bytes.Buffer) - stdErr := new(bytes.Buffer) - - if err := cmd.Run(ctx, &gitcmd.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()) + stdout, _, err := cmd.WithEnv(append(os.Environ(), envs...)). + WithDir(gitRepo.Path). + RunStdBytes(ctx) + if err != nil { + return nil, fmt.Errorf("failed to run check-attr: %w", err) } - fields := bytes.Split(stdOut.Bytes(), []byte{'\000'}) + fields := bytes.Split(stdout, []byte{'\000'}) if len(fields)%3 != 1 { return nil, errors.New("wrong number of fields in return from check-attr") } diff --git a/modules/git/batch.go b/modules/git/batch.go deleted file mode 100644 index f9e1748b548..00000000000 --- a/modules/git/batch.go +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package git - -import ( - "bufio" - "context" -) - -type Batch struct { - cancel context.CancelFunc - Reader *bufio.Reader - Writer WriteCloserError -} - -// NewBatch creates a new batch for the given repository, the Close must be invoked before release the batch -func NewBatch(ctx context.Context, repoPath string) (*Batch, error) { - // Now because of some insanity with git cat-file not immediately failing if not run in a valid git directory we need to run git rev-parse first! - if err := ensureValidGitRepository(ctx, repoPath); err != nil { - return nil, err - } - - var batch Batch - batch.Writer, batch.Reader, batch.cancel = catFileBatch(ctx, repoPath) - return &batch, nil -} - -func NewBatchCheck(ctx context.Context, repoPath string) (*Batch, error) { - // Now because of some insanity with git cat-file not immediately failing if not run in a valid git directory we need to run git rev-parse first! - if err := ensureValidGitRepository(ctx, repoPath); err != nil { - return nil, err - } - - var check Batch - check.Writer, check.Reader, check.cancel = catFileBatchCheck(ctx, repoPath) - return &check, nil -} - -func (b *Batch) Close() { - if b.cancel != nil { - b.cancel() - b.Reader = nil - b.Writer = nil - b.cancel = nil - } -} diff --git a/modules/git/batch_reader.go b/modules/git/batch_reader.go deleted file mode 100644 index f09f4144c89..00000000000 --- a/modules/git/batch_reader.go +++ /dev/null @@ -1,329 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package git - -import ( - "bufio" - "bytes" - "context" - "io" - "math" - "strconv" - "strings" - - "code.gitea.io/gitea/modules/git/gitcmd" - "code.gitea.io/gitea/modules/log" - - "github.com/djherbis/buffer" - "github.com/djherbis/nio/v3" -) - -// WriteCloserError wraps an io.WriteCloser with an additional CloseWithError function -type WriteCloserError interface { - io.WriteCloser - CloseWithError(err error) error -} - -// ensureValidGitRepository runs git rev-parse in the repository path - thus ensuring that the repository is a valid repository. -// Run before opening git cat-file. -// This is needed otherwise the git cat-file will hang for invalid repositories. -func ensureValidGitRepository(ctx context.Context, repoPath string) error { - stderr := strings.Builder{} - err := gitcmd.NewCommand("rev-parse"). - Run(ctx, &gitcmd.RunOpts{ - Dir: repoPath, - Stderr: &stderr, - }) - if err != nil { - return gitcmd.ConcatenateError(err, (&stderr).String()) - } - return nil -} - -// catFileBatchCheck opens git cat-file --batch-check in the provided repo and returns a stdin pipe, a stdout reader and cancel function -func catFileBatchCheck(ctx context.Context, repoPath string) (WriteCloserError, *bufio.Reader, func()) { - batchStdinReader, batchStdinWriter := io.Pipe() - batchStdoutReader, batchStdoutWriter := io.Pipe() - ctx, ctxCancel := context.WithCancel(ctx) - closed := make(chan struct{}) - cancel := func() { - ctxCancel() - _ = batchStdoutReader.Close() - _ = batchStdinWriter.Close() - <-closed - } - - // Ensure cancel is called as soon as the provided context is cancelled - go func() { - <-ctx.Done() - cancel() - }() - - go func() { - stderr := strings.Builder{} - err := gitcmd.NewCommand("cat-file", "--batch-check"). - Run(ctx, &gitcmd.RunOpts{ - Dir: repoPath, - Stdin: batchStdinReader, - Stdout: batchStdoutWriter, - Stderr: &stderr, - - UseContextTimeout: true, - }) - if err != nil { - _ = batchStdoutWriter.CloseWithError(gitcmd.ConcatenateError(err, (&stderr).String())) - _ = batchStdinReader.CloseWithError(gitcmd.ConcatenateError(err, (&stderr).String())) - } else { - _ = batchStdoutWriter.Close() - _ = batchStdinReader.Close() - } - close(closed) - }() - - // For simplicities sake we'll use a buffered reader to read from the cat-file --batch-check - batchReader := bufio.NewReader(batchStdoutReader) - - return batchStdinWriter, batchReader, cancel -} - -// catFileBatch opens git cat-file --batch in the provided repo and returns a stdin pipe, a stdout reader and cancel function -func catFileBatch(ctx context.Context, repoPath string) (WriteCloserError, *bufio.Reader, func()) { - // We often want to feed the commits in order into cat-file --batch, followed by their trees and sub trees as necessary. - // so let's create a batch stdin and stdout - batchStdinReader, batchStdinWriter := io.Pipe() - batchStdoutReader, batchStdoutWriter := nio.Pipe(buffer.New(32 * 1024)) - ctx, ctxCancel := context.WithCancel(ctx) - closed := make(chan struct{}) - cancel := func() { - ctxCancel() - _ = batchStdinWriter.Close() - _ = batchStdoutReader.Close() - <-closed - } - - // Ensure cancel is called as soon as the provided context is cancelled - go func() { - <-ctx.Done() - cancel() - }() - - go func() { - stderr := strings.Builder{} - err := gitcmd.NewCommand("cat-file", "--batch"). - Run(ctx, &gitcmd.RunOpts{ - Dir: repoPath, - Stdin: batchStdinReader, - Stdout: batchStdoutWriter, - Stderr: &stderr, - - UseContextTimeout: true, - }) - if err != nil { - _ = batchStdoutWriter.CloseWithError(gitcmd.ConcatenateError(err, (&stderr).String())) - _ = batchStdinReader.CloseWithError(gitcmd.ConcatenateError(err, (&stderr).String())) - } else { - _ = batchStdoutWriter.Close() - _ = batchStdinReader.Close() - } - close(closed) - }() - - // For simplicities sake we'll us a buffered reader to read from the cat-file --batch - batchReader := bufio.NewReaderSize(batchStdoutReader, 32*1024) - - return batchStdinWriter, batchReader, cancel -} - -// ReadBatchLine reads the header line from cat-file --batch -// We expect: SP SP LF -// then leaving the rest of the stream " LF" to be read -func ReadBatchLine(rd *bufio.Reader) (sha []byte, typ string, size int64, err error) { - typ, err = rd.ReadString('\n') - if err != nil { - return sha, typ, size, err - } - if len(typ) == 1 { - typ, err = rd.ReadString('\n') - if err != nil { - return sha, typ, size, err - } - } - idx := strings.IndexByte(typ, ' ') - if idx < 0 { - log.Debug("missing space typ: %s", typ) - return sha, typ, size, ErrNotExist{ID: string(sha)} - } - sha = []byte(typ[:idx]) - typ = typ[idx+1:] - - idx = strings.IndexByte(typ, ' ') - if idx < 0 { - return sha, typ, size, ErrNotExist{ID: string(sha)} - } - - sizeStr := typ[idx+1 : len(typ)-1] - typ = typ[:idx] - - size, err = strconv.ParseInt(sizeStr, 10, 64) - return sha, typ, size, err -} - -// ReadTagObjectID reads a tag object ID hash from a cat-file --batch stream, throwing away the rest of the stream. -func ReadTagObjectID(rd *bufio.Reader, size int64) (string, error) { - var id string - var n int64 -headerLoop: - for { - line, err := rd.ReadBytes('\n') - if err != nil { - return "", err - } - n += int64(len(line)) - idx := bytes.Index(line, []byte{' '}) - if idx < 0 { - continue - } - - if string(line[:idx]) == "object" { - id = string(line[idx+1 : len(line)-1]) - break headerLoop - } - } - - // Discard the rest of the tag - return id, DiscardFull(rd, size-n+1) -} - -// ReadTreeID reads a tree ID from a cat-file --batch stream, throwing away the rest of the stream. -func ReadTreeID(rd *bufio.Reader, size int64) (string, error) { - var id string - var n int64 -headerLoop: - for { - line, err := rd.ReadBytes('\n') - if err != nil { - return "", err - } - n += int64(len(line)) - idx := bytes.Index(line, []byte{' '}) - if idx < 0 { - continue - } - - if string(line[:idx]) == "tree" { - id = string(line[idx+1 : len(line)-1]) - break headerLoop - } - } - - // Discard the rest of the commit - return id, DiscardFull(rd, size-n+1) -} - -// git tree files are a list: -// SP NUL -// -// Unfortunately this 20-byte notation is somewhat in conflict to all other git tools -// Therefore we need some method to convert these binary hashes to hex hashes - -// constant hextable to help quickly convert between binary and hex representation -const hextable = "0123456789abcdef" - -// BinToHexHeash converts a binary Hash into a hex encoded one. Input and output can be the -// same byte slice to support in place conversion without allocations. -// This is at least 100x quicker that hex.EncodeToString -func BinToHex(objectFormat ObjectFormat, sha, out []byte) []byte { - for i := objectFormat.FullLength()/2 - 1; i >= 0; i-- { - v := sha[i] - vhi, vlo := v>>4, v&0x0f - shi, slo := hextable[vhi], hextable[vlo] - out[i*2], out[i*2+1] = shi, slo - } - return out -} - -// ParseCatFileTreeLine reads an entry from a tree in a cat-file --batch stream -// This carefully avoids allocations - except where fnameBuf is too small. -// It is recommended therefore to pass in an fnameBuf large enough to avoid almost all allocations -// -// Each line is composed of: -// SP NUL -// -// We don't attempt to convert the raw HASH to save a lot of time -func ParseCatFileTreeLine(objectFormat ObjectFormat, rd *bufio.Reader, modeBuf, fnameBuf, shaBuf []byte) (mode, fname, sha []byte, n int, err error) { - var readBytes []byte - - // Read the Mode & fname - readBytes, err = rd.ReadSlice('\x00') - if err != nil { - return mode, fname, sha, n, err - } - idx := bytes.IndexByte(readBytes, ' ') - if idx < 0 { - log.Debug("missing space in readBytes ParseCatFileTreeLine: %s", readBytes) - return mode, fname, sha, n, &ErrNotExist{} - } - - n += idx + 1 - copy(modeBuf, readBytes[:idx]) - if len(modeBuf) >= idx { - modeBuf = modeBuf[:idx] - } else { - modeBuf = append(modeBuf, readBytes[len(modeBuf):idx]...) - } - mode = modeBuf - - readBytes = readBytes[idx+1:] - - // Deal with the fname - copy(fnameBuf, readBytes) - if len(fnameBuf) > len(readBytes) { - fnameBuf = fnameBuf[:len(readBytes)] - } else { - fnameBuf = append(fnameBuf, readBytes[len(fnameBuf):]...) - } - for err == bufio.ErrBufferFull { - readBytes, err = rd.ReadSlice('\x00') - fnameBuf = append(fnameBuf, readBytes...) - } - n += len(fnameBuf) - if err != nil { - return mode, fname, sha, n, err - } - fnameBuf = fnameBuf[:len(fnameBuf)-1] - fname = fnameBuf - - // Deal with the binary hash - idx = 0 - length := objectFormat.FullLength() / 2 - for idx < length { - var read int - read, err = rd.Read(shaBuf[idx:length]) - n += read - if err != nil { - return mode, fname, sha, n, err - } - idx += read - } - sha = shaBuf - return mode, fname, sha, n, err -} - -func DiscardFull(rd *bufio.Reader, discard int64) error { - if discard > math.MaxInt32 { - n, err := rd.Discard(math.MaxInt32) - discard -= int64(n) - if err != nil { - return err - } - } - for discard > 0 { - n, err := rd.Discard(int(discard)) - discard -= int64(n) - if err != nil { - return err - } - } - return nil -} diff --git a/modules/git/blob_gogit.go b/modules/git/blob_gogit.go index 8c79c067c17..7cf622d180f 100644 --- a/modules/git/blob_gogit.go +++ b/modules/git/blob_gogit.go @@ -9,24 +9,38 @@ package git import ( "io" + "code.gitea.io/gitea/modules/log" + "github.com/go-git/go-git/v5/plumbing" ) // Blob represents a Git object. type Blob struct { - ID ObjectID + ID ObjectID + repo *Repository + name string +} - gogitEncodedObj plumbing.EncodedObject - name string +func (b *Blob) gogitEncodedObj() (plumbing.EncodedObject, error) { + return b.repo.gogitRepo.Storer.EncodedObject(plumbing.AnyObject, plumbing.Hash(b.ID.RawValue())) } // DataAsync gets a ReadCloser for the contents of a blob without reading it all. // Calling the Close function on the result will discard all unread output. func (b *Blob) DataAsync() (io.ReadCloser, error) { - return b.gogitEncodedObj.Reader() + obj, err := b.gogitEncodedObj() + if err != nil { + return nil, err + } + return obj.Reader() } // Size returns the uncompressed size of the blob func (b *Blob) Size() int64 { - return b.gogitEncodedObj.Size() + obj, err := b.gogitEncodedObj() + if err != nil { + log.Error("Error getting gogit encoded object for blob %s(%s): %v", b.name, b.ID.String(), err) + return 0 + } + return obj.Size() } diff --git a/modules/git/blob_nogogit.go b/modules/git/blob_nogogit.go index af3ce376d6a..837b30fd883 100644 --- a/modules/git/blob_nogogit.go +++ b/modules/git/blob_nogogit.go @@ -6,8 +6,6 @@ package git import ( - "bufio" - "bytes" "io" "code.gitea.io/gitea/modules/log" @@ -25,38 +23,28 @@ type Blob struct { // DataAsync gets a ReadCloser for the contents of a blob without reading it all. // Calling the Close function on the result will discard all unread output. -func (b *Blob) DataAsync() (io.ReadCloser, error) { - wr, rd, cancel, err := b.repo.CatFileBatch(b.repo.Ctx) +func (b *Blob) DataAsync() (_ io.ReadCloser, retErr error) { + batch, cancel, err := b.repo.CatFileBatch(b.repo.Ctx) if err != nil { return nil, err } + defer func() { + // if there was an error, cancel the batch right away, + // otherwise let the caller close it + if retErr != nil { + cancel() + } + }() - _, err = wr.Write([]byte(b.ID.String() + "\n")) + info, contentReader, err := batch.QueryContent(b.ID.String()) if err != nil { - cancel() - return nil, err - } - _, _, size, err := ReadBatchLine(rd) - if err != nil { - cancel() return nil, err } b.gotSize = true - b.size = size - - if size < 4096 { - bs, err := io.ReadAll(io.LimitReader(rd, size)) - defer cancel() - if err != nil { - return nil, err - } - _, err = rd.Discard(1) - return io.NopCloser(bytes.NewReader(bs)), err - } - + b.size = info.Size return &blobReader{ - rd: rd, - n: size, + rd: contentReader, + n: info.Size, cancel: cancel, }, nil } @@ -67,30 +55,24 @@ func (b *Blob) Size() int64 { return b.size } - wr, rd, cancel, err := b.repo.CatFileBatchCheck(b.repo.Ctx) + batch, cancel, err := b.repo.CatFileBatch(b.repo.Ctx) if err != nil { log.Debug("error whilst reading size for %s in %s. Error: %v", b.ID.String(), b.repo.Path, err) return 0 } defer cancel() - _, err = wr.Write([]byte(b.ID.String() + "\n")) + info, err := batch.QueryInfo(b.ID.String()) if err != nil { log.Debug("error whilst reading size for %s in %s. Error: %v", b.ID.String(), b.repo.Path, err) return 0 } - _, _, b.size, err = ReadBatchLine(rd) - if err != nil { - log.Debug("error whilst reading size for %s in %s. Error: %v", b.ID.String(), b.repo.Path, err) - return 0 - } - b.gotSize = true - + b.size = info.Size return b.size } type blobReader struct { - rd *bufio.Reader + rd BufferedReader n int64 cancel func() } diff --git a/modules/git/catfile_batch.go b/modules/git/catfile_batch.go new file mode 100644 index 00000000000..d13179f3ec7 --- /dev/null +++ b/modules/git/catfile_batch.go @@ -0,0 +1,52 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "context" + "io" +) + +type BufferedReader interface { + io.Reader + Buffered() int + Peek(n int) ([]byte, error) + Discard(n int) (int, error) + ReadString(sep byte) (string, error) + ReadSlice(sep byte) ([]byte, error) + ReadBytes(sep byte) ([]byte, error) +} + +type CatFileObject struct { + ID string + Type string + Size int64 +} + +type CatFileBatch interface { + // QueryInfo queries the object info from the git repository by its object name using "git cat-file --batch" family commands. + // "git cat-file" accepts "" for the object name, it can be a ref name, object id, etc. https://git-scm.com/docs/gitrevisions + // In Gitea, we only use the simple ref name or object id, no other complex rev syntax like "suffix" or "git describe" although they are supported by git. + QueryInfo(obj string) (*CatFileObject, error) + + // QueryContent is similar to QueryInfo, it queries the object info and additionally returns a reader for its content. + // FIXME: this design still follows the old pattern: the returned BufferedReader is very fragile, + // callers should carefully maintain its lifecycle and discard all unread data. + // TODO: It needs to be refactored to a fully managed Reader stream in the future, don't let callers manually Close or Discard + QueryContent(obj string) (*CatFileObject, BufferedReader, error) +} + +type CatFileBatchCloser interface { + CatFileBatch + Close() +} + +// NewBatch creates a "batch object provider (CatFileBatch)" for the given repository path to retrieve object info and content efficiently. +// The CatFileBatch and the readers create by it should only be used in the same goroutine. +func NewBatch(ctx context.Context, repoPath string) (CatFileBatchCloser, error) { + if DefaultFeatures().SupportCatFileBatchCommand { + return newCatFileBatchCommand(ctx, repoPath) + } + return newCatFileBatchLegacy(ctx, repoPath) +} diff --git a/modules/git/catfile_batch_command.go b/modules/git/catfile_batch_command.go new file mode 100644 index 00000000000..710561f0451 --- /dev/null +++ b/modules/git/catfile_batch_command.go @@ -0,0 +1,66 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "context" + "os" + "path/filepath" + + "code.gitea.io/gitea/modules/git/gitcmd" + "code.gitea.io/gitea/modules/util" +) + +// catFileBatchCommand implements the CatFileBatch interface using the "cat-file --batch-command" command +// for git version >= 2.36 +// ref: https://git-scm.com/docs/git-cat-file#Documentation/git-cat-file.txt---batch-command +type catFileBatchCommand struct { + ctx context.Context + repoPath string + batch *catFileBatchCommunicator +} + +var _ CatFileBatch = (*catFileBatchCommand)(nil) + +func newCatFileBatchCommand(ctx context.Context, repoPath string) (*catFileBatchCommand, error) { + if _, err := os.Stat(repoPath); err != nil { + return nil, util.NewNotExistErrorf("repo %q doesn't exist", filepath.Base(repoPath)) + } + return &catFileBatchCommand{ctx: ctx, repoPath: repoPath}, nil +} + +func (b *catFileBatchCommand) getBatch() *catFileBatchCommunicator { + if b.batch != nil { + return b.batch + } + b.batch = newCatFileBatch(b.ctx, b.repoPath, gitcmd.NewCommand("cat-file", "--batch-command")) + return b.batch +} + +func (b *catFileBatchCommand) QueryContent(obj string) (*CatFileObject, BufferedReader, error) { + _, err := b.getBatch().reqWriter.Write([]byte("contents " + obj + "\n")) + if err != nil { + return nil, nil, err + } + info, err := catFileBatchParseInfoLine(b.getBatch().respReader) + if err != nil { + return nil, nil, err + } + return info, b.getBatch().respReader, nil +} + +func (b *catFileBatchCommand) QueryInfo(obj string) (*CatFileObject, error) { + _, err := b.getBatch().reqWriter.Write([]byte("info " + obj + "\n")) + if err != nil { + return nil, err + } + return catFileBatchParseInfoLine(b.getBatch().respReader) +} + +func (b *catFileBatchCommand) Close() { + if b.batch != nil { + b.batch.Close() + b.batch = nil + } +} diff --git a/modules/git/catfile_batch_legacy.go b/modules/git/catfile_batch_legacy.go new file mode 100644 index 00000000000..795fc4ce3dd --- /dev/null +++ b/modules/git/catfile_batch_legacy.go @@ -0,0 +1,81 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "context" + "io" + "os" + "path/filepath" + + "code.gitea.io/gitea/modules/git/gitcmd" + "code.gitea.io/gitea/modules/util" +) + +// catFileBatchLegacy implements the CatFileBatch interface using the "cat-file --batch" command and "cat-file --batch-check" command +// for git version < 2.36 +// to align with "--batch-command", it creates the two commands for querying object contents and object info separately +// ref: https://git-scm.com/docs/git-cat-file#Documentation/git-cat-file.txt---batch +type catFileBatchLegacy struct { + ctx context.Context + repoPath string + batchContent *catFileBatchCommunicator + batchCheck *catFileBatchCommunicator +} + +var _ CatFileBatchCloser = (*catFileBatchLegacy)(nil) + +func newCatFileBatchLegacy(ctx context.Context, repoPath string) (*catFileBatchLegacy, error) { + if _, err := os.Stat(repoPath); err != nil { + return nil, util.NewNotExistErrorf("repo %q doesn't exist", filepath.Base(repoPath)) + } + return &catFileBatchLegacy{ctx: ctx, repoPath: repoPath}, nil +} + +func (b *catFileBatchLegacy) getBatchContent() *catFileBatchCommunicator { + if b.batchContent != nil { + return b.batchContent + } + b.batchContent = newCatFileBatch(b.ctx, b.repoPath, gitcmd.NewCommand("cat-file", "--batch")) + return b.batchContent +} + +func (b *catFileBatchLegacy) getBatchCheck() *catFileBatchCommunicator { + if b.batchCheck != nil { + return b.batchCheck + } + b.batchCheck = newCatFileBatch(b.ctx, b.repoPath, gitcmd.NewCommand("cat-file", "--batch-check")) + return b.batchCheck +} + +func (b *catFileBatchLegacy) QueryContent(obj string) (*CatFileObject, BufferedReader, error) { + _, err := io.WriteString(b.getBatchContent().reqWriter, obj+"\n") + if err != nil { + return nil, nil, err + } + info, err := catFileBatchParseInfoLine(b.getBatchContent().respReader) + if err != nil { + return nil, nil, err + } + return info, b.getBatchContent().respReader, nil +} + +func (b *catFileBatchLegacy) QueryInfo(obj string) (*CatFileObject, error) { + _, err := io.WriteString(b.getBatchCheck().reqWriter, obj+"\n") + if err != nil { + return nil, err + } + return catFileBatchParseInfoLine(b.getBatchCheck().respReader) +} + +func (b *catFileBatchLegacy) Close() { + if b.batchContent != nil { + b.batchContent.Close() + b.batchContent = nil + } + if b.batchCheck != nil { + b.batchCheck.Close() + b.batchCheck = nil + } +} diff --git a/modules/git/catfile_batch_reader.go b/modules/git/catfile_batch_reader.go new file mode 100644 index 00000000000..8a0b3420795 --- /dev/null +++ b/modules/git/catfile_batch_reader.go @@ -0,0 +1,254 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "bufio" + "bytes" + "context" + "errors" + "io" + "math" + "strconv" + "strings" + "sync/atomic" + "time" + + "code.gitea.io/gitea/modules/git/gitcmd" + "code.gitea.io/gitea/modules/log" +) + +var catFileBatchDebugWaitClose atomic.Int64 + +type catFileBatchCommunicator struct { + cancel context.CancelFunc + reqWriter io.Writer + respReader *bufio.Reader + debugGitCmd *gitcmd.Command +} + +func (b *catFileBatchCommunicator) Close() { + if b.cancel != nil { + b.cancel() + b.cancel = nil + } +} + +// newCatFileBatch opens git cat-file --batch in the provided repo and returns a stdin pipe, a stdout reader and cancel function +func newCatFileBatch(ctx context.Context, repoPath string, cmdCatFile *gitcmd.Command) (ret *catFileBatchCommunicator) { + ctx, ctxCancel := context.WithCancelCause(ctx) + + // We often want to feed the commits in order into cat-file --batch, followed by their trees and subtrees as necessary. + stdinWriter, stdoutReader, stdPipeClose := cmdCatFile.MakeStdinStdoutPipe() + pipeClose := func() { + if delay := catFileBatchDebugWaitClose.Load(); delay > 0 { + time.Sleep(time.Duration(delay)) // for testing purpose only + } + stdPipeClose() + } + + ret = &catFileBatchCommunicator{ + debugGitCmd: cmdCatFile, + cancel: func() { ctxCancel(nil) }, + reqWriter: stdinWriter, + respReader: bufio.NewReaderSize(stdoutReader, 32*1024), // use a buffered reader for rich operations + } + + err := cmdCatFile.WithDir(repoPath).StartWithStderr(ctx) + if err != nil { + log.Error("Unable to start git command %v: %v", cmdCatFile.LogString(), err) + // ideally here it should return the error, but it would require refactoring all callers + // so just return a dummy communicator that does nothing, almost the same behavior as before, not bad + ctxCancel(err) + pipeClose() + return ret + } + + go func() { + err := cmdCatFile.WaitWithStderr() + if err != nil && !errors.Is(err, context.Canceled) { + log.Error("cat-file --batch command failed in repo %s, error: %v", repoPath, err) + } + ctxCancel(err) + pipeClose() + }() + + return ret +} + +// catFileBatchParseInfoLine reads the header line from cat-file --batch +// We expect: SP SP LF +// then leaving the rest of the stream " LF" to be read +func catFileBatchParseInfoLine(rd BufferedReader) (*CatFileObject, error) { + typ, err := rd.ReadString('\n') + if err != nil { + return nil, err + } + if len(typ) == 1 { + typ, err = rd.ReadString('\n') + if err != nil { + return nil, err + } + } + idx := strings.IndexByte(typ, ' ') + if idx < 0 { + return nil, ErrNotExist{} + } + sha := typ[:idx] + typ = typ[idx+1:] + + idx = strings.IndexByte(typ, ' ') + if idx < 0 { + return nil, ErrNotExist{ID: sha} + } + + sizeStr := typ[idx+1 : len(typ)-1] + typ = typ[:idx] + + size, err := strconv.ParseInt(sizeStr, 10, 64) + return &CatFileObject{ID: sha, Type: typ, Size: size}, err +} + +// ReadTagObjectID reads a tag object ID hash from a cat-file --batch stream, throwing away the rest of the stream. +func ReadTagObjectID(rd BufferedReader, size int64) (string, error) { + var id string + var n int64 +headerLoop: + for { + line, err := rd.ReadBytes('\n') + if err != nil { + return "", err + } + n += int64(len(line)) + idx := bytes.Index(line, []byte{' '}) + if idx < 0 { + continue + } + + if string(line[:idx]) == "object" { + id = string(line[idx+1 : len(line)-1]) + break headerLoop + } + } + + // Discard the rest of the tag + return id, DiscardFull(rd, size-n+1) +} + +// ReadTreeID reads a tree ID from a cat-file --batch stream, throwing away the rest of the stream. +func ReadTreeID(rd BufferedReader, size int64) (string, error) { + var id string + var n int64 +headerLoop: + for { + line, err := rd.ReadBytes('\n') + if err != nil { + return "", err + } + n += int64(len(line)) + idx := bytes.Index(line, []byte{' '}) + if idx < 0 { + continue + } + + if string(line[:idx]) == "tree" { + id = string(line[idx+1 : len(line)-1]) + break headerLoop + } + } + + // Discard the rest of the commit + return id, DiscardFull(rd, size-n+1) +} + +// git tree files are a list: +// SP NUL +// +// Unfortunately this 20-byte notation is somewhat in conflict to all other git tools +// Therefore we need some method to convert these binary hashes to hex hashes + +// ParseCatFileTreeLine reads an entry from a tree in a cat-file --batch stream +// This carefully avoids allocations - except where fnameBuf is too small. +// It is recommended therefore to pass in an fnameBuf large enough to avoid almost all allocations +// +// Each line is composed of: +// SP NUL +// +// We don't attempt to convert the raw HASH to save a lot of time +func ParseCatFileTreeLine(objectFormat ObjectFormat, rd BufferedReader, modeBuf, fnameBuf, shaBuf []byte) (mode, fname, sha []byte, n int, err error) { + var readBytes []byte + + // Read the Mode & fname + readBytes, err = rd.ReadSlice('\x00') + if err != nil { + return mode, fname, sha, n, err + } + idx := bytes.IndexByte(readBytes, ' ') + if idx < 0 { + log.Debug("missing space in readBytes ParseCatFileTreeLine: %s", readBytes) + return mode, fname, sha, n, &ErrNotExist{} + } + + n += idx + 1 + copy(modeBuf, readBytes[:idx]) + if len(modeBuf) >= idx { + modeBuf = modeBuf[:idx] + } else { + modeBuf = append(modeBuf, readBytes[len(modeBuf):idx]...) + } + mode = modeBuf + + readBytes = readBytes[idx+1:] + + // Deal with the fname + copy(fnameBuf, readBytes) + if len(fnameBuf) > len(readBytes) { + fnameBuf = fnameBuf[:len(readBytes)] + } else { + fnameBuf = append(fnameBuf, readBytes[len(fnameBuf):]...) + } + for err == bufio.ErrBufferFull { + readBytes, err = rd.ReadSlice('\x00') + fnameBuf = append(fnameBuf, readBytes...) + } + n += len(fnameBuf) + if err != nil { + return mode, fname, sha, n, err + } + fnameBuf = fnameBuf[:len(fnameBuf)-1] + fname = fnameBuf + + // Deal with the binary hash + idx = 0 + length := objectFormat.FullLength() / 2 + for idx < length { + var read int + read, err = rd.Read(shaBuf[idx:length]) + n += read + if err != nil { + return mode, fname, sha, n, err + } + idx += read + } + sha = shaBuf + return mode, fname, sha, n, err +} + +func DiscardFull(rd BufferedReader, discard int64) error { + if discard > math.MaxInt32 { + n, err := rd.Discard(math.MaxInt32) + discard -= int64(n) + if err != nil { + return err + } + } + for discard > 0 { + n, err := rd.Discard(int(discard)) + discard -= int64(n) + if err != nil { + return err + } + } + return nil +} diff --git a/modules/git/catfile_batch_test.go b/modules/git/catfile_batch_test.go new file mode 100644 index 00000000000..69662ffc1a7 --- /dev/null +++ b/modules/git/catfile_batch_test.go @@ -0,0 +1,104 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "io" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "code.gitea.io/gitea/modules/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCatFileBatch(t *testing.T) { + defer test.MockVariableValue(&DefaultFeatures().SupportCatFileBatchCommand)() + DefaultFeatures().SupportCatFileBatchCommand = false + t.Run("LegacyCheck", testCatFileBatch) + DefaultFeatures().SupportCatFileBatchCommand = true + t.Run("BatchCommand", testCatFileBatch) +} + +func testCatFileBatch(t *testing.T) { + t.Run("CorruptedGitRepo", func(t *testing.T) { + tmpDir := t.TempDir() + batch, err := NewBatch(t.Context(), tmpDir) + // as long as the directory exists, no error, because we can't really know whether the git repo is valid until we run commands + require.NoError(t, err) + defer batch.Close() + + _, err = batch.QueryInfo("e2129701f1a4d54dc44f03c93bca0a2aec7c5449") + require.Error(t, err) + _, err = batch.QueryInfo("e2129701f1a4d54dc44f03c93bca0a2aec7c5449") + require.Error(t, err) + }) + + simulateQueryTerminated := func(pipeCloseDelay, pipeReadDelay time.Duration) (errRead error) { + catFileBatchDebugWaitClose.Store(int64(pipeCloseDelay)) + defer catFileBatchDebugWaitClose.Store(0) + batch, err := NewBatch(t.Context(), filepath.Join(testReposDir, "repo1_bare")) + require.NoError(t, err) + defer batch.Close() + _, _ = batch.QueryInfo("e2129701f1a4d54dc44f03c93bca0a2aec7c5449") + var c *catFileBatchCommunicator + switch b := batch.(type) { + case *catFileBatchLegacy: + c = b.batchCheck + _, _ = c.reqWriter.Write([]byte("in-complete-line-")) + case *catFileBatchCommand: + c = b.batch + _, _ = c.reqWriter.Write([]byte("info")) + default: + t.FailNow() + } + + wg := sync.WaitGroup{} + wg.Go(func() { + time.Sleep(pipeReadDelay) + var n int + n, errRead = c.respReader.Read(make([]byte, 100)) + assert.Zero(t, n) + }) + time.Sleep(10 * time.Millisecond) + c.debugGitCmd.DebugKill() + wg.Wait() + return errRead + } + + t.Run("QueryTerminated", func(t *testing.T) { + err := simulateQueryTerminated(0, 20*time.Millisecond) + assert.ErrorIs(t, err, os.ErrClosed) // pipes are closed faster + err = simulateQueryTerminated(40*time.Millisecond, 20*time.Millisecond) + assert.ErrorIs(t, err, io.EOF) // reader is faster + }) + + batch, err := NewBatch(t.Context(), filepath.Join(testReposDir, "repo1_bare")) + require.NoError(t, err) + defer batch.Close() + + t.Run("QueryInfo", func(t *testing.T) { + info, err := batch.QueryInfo("e2129701f1a4d54dc44f03c93bca0a2aec7c5449") + require.NoError(t, err) + assert.Equal(t, "e2129701f1a4d54dc44f03c93bca0a2aec7c5449", info.ID) + assert.Equal(t, "blob", info.Type) + assert.EqualValues(t, 6, info.Size) + }) + + t.Run("QueryContent", func(t *testing.T) { + info, rd, err := batch.QueryContent("e2129701f1a4d54dc44f03c93bca0a2aec7c5449") + require.NoError(t, err) + assert.Equal(t, "e2129701f1a4d54dc44f03c93bca0a2aec7c5449", info.ID) + assert.Equal(t, "blob", info.Type) + assert.EqualValues(t, 6, info.Size) + + content, err := io.ReadAll(io.LimitReader(rd, info.Size)) + require.NoError(t, err) + require.Equal(t, "file1\n", string(content)) + }) +} diff --git a/modules/git/commit.go b/modules/git/commit.go index a0c5955ae8b..dfecfe60575 100644 --- a/modules/git/commit.go +++ b/modules/git/commit.go @@ -5,17 +5,13 @@ package git import ( - "bufio" - "bytes" "context" "errors" "io" "os/exec" - "strconv" "strings" "code.gitea.io/gitea/modules/git/gitcmd" - "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/util" ) @@ -41,6 +37,10 @@ type CommitSignature struct { // Message returns the commit message. Same as retrieving CommitMessage directly. func (c *Commit) Message() string { + // FIXME: GIT-COMMIT-MESSAGE-ENCODING: this logic is not right + // * When need to use commit message in templates/database, it should be valid UTF-8 + // * When need to get the original commit message, it should just use "c.CommitMessage" + // It's not easy to refactor at the moment, many templates need to be updated and tested return c.CommitMessage } @@ -86,109 +86,6 @@ func (c *Commit) GetCommitByPath(relpath string) (*Commit, error) { return c.repo.getCommitByPathWithID(c.ID, relpath) } -// AddChanges marks local changes to be ready for commit. -func AddChanges(ctx context.Context, repoPath string, all bool, files ...string) error { - cmd := gitcmd.NewCommand().AddArguments("add") - if all { - cmd.AddArguments("--all") - } - cmd.AddDashesAndList(files...) - _, _, err := cmd.RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath}) - return err -} - -// CommitChangesOptions the options when a commit created -type CommitChangesOptions struct { - Committer *Signature - Author *Signature - Message string -} - -// CommitChanges commits local changes with given committer, author and message. -// If author is nil, it will be the same as committer. -func CommitChanges(ctx context.Context, repoPath string, opts CommitChangesOptions) error { - cmd := gitcmd.NewCommand() - if opts.Committer != nil { - cmd.AddOptionValues("-c", "user.name="+opts.Committer.Name) - cmd.AddOptionValues("-c", "user.email="+opts.Committer.Email) - } - cmd.AddArguments("commit") - - if opts.Author == nil { - opts.Author = opts.Committer - } - if opts.Author != nil { - cmd.AddOptionFormat("--author='%s <%s>'", opts.Author.Name, opts.Author.Email) - } - cmd.AddOptionFormat("--message=%s", opts.Message) - - _, _, err := cmd.RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath}) - // No stderr but exit status 1 means nothing to commit. - if err != nil && err.Error() == "exit status 1" { - return nil - } - return err -} - -// AllCommitsCount returns count of all commits in repository -func AllCommitsCount(ctx context.Context, repoPath string, hidePRRefs bool, files ...string) (int64, error) { - cmd := gitcmd.NewCommand("rev-list") - if hidePRRefs { - cmd.AddArguments("--exclude=" + PullPrefix + "*") - } - cmd.AddArguments("--all", "--count") - if len(files) > 0 { - cmd.AddDashesAndList(files...) - } - - stdout, _, err := cmd.RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath}) - if err != nil { - return 0, err - } - - return strconv.ParseInt(strings.TrimSpace(stdout), 10, 64) -} - -// CommitsCountOptions the options when counting commits -type CommitsCountOptions struct { - RepoPath string - Not string - Revision []string - RelPath []string - Since string - Until string -} - -// CommitsCount returns number of total commits of until given revision. -func CommitsCount(ctx context.Context, opts CommitsCountOptions) (int64, error) { - cmd := gitcmd.NewCommand("rev-list", "--count") - - cmd.AddDynamicArguments(opts.Revision...) - - if opts.Not != "" { - cmd.AddOptionValues("--not", opts.Not) - } - - if len(opts.RelPath) > 0 { - cmd.AddDashesAndList(opts.RelPath...) - } - - stdout, _, err := cmd.RunStdString(ctx, &gitcmd.RunOpts{Dir: opts.RepoPath}) - if err != nil { - return 0, err - } - - return strconv.ParseInt(strings.TrimSpace(stdout), 10, 64) -} - -// CommitsCount returns number of total commits of until current revision. -func (c *Commit) CommitsCount() (int64, error) { - return CommitsCount(c.repo.Ctx, CommitsCountOptions{ - RepoPath: c.repo.Path, - Revision: []string{c.ID.String()}, - }) -} - // CommitsByRange returns the specific page commits before current revision, every page's number default by CommitsRangeSize func (c *Commit) CommitsByRange(page, pageSize int, not, since, until string) ([]*Commit, error) { return c.repo.commitsByRangeWithTime(c.ID, page, pageSize, not, since, until) @@ -208,7 +105,10 @@ func (c *Commit) HasPreviousCommit(objectID ObjectID) (bool, error) { return false, nil } - _, _, err := gitcmd.NewCommand("merge-base", "--is-ancestor").AddDynamicArguments(that, this).RunStdString(c.repo.Ctx, &gitcmd.RunOpts{Dir: c.repo.Path}) + _, _, err := gitcmd.NewCommand("merge-base", "--is-ancestor"). + AddDynamicArguments(that, this). + WithDir(c.repo.Path). + RunStdString(c.repo.Ctx) if err == nil { return true, nil } @@ -354,7 +254,7 @@ func (c *Commit) GetBranchName() (string, error) { cmd.AddArguments("--exclude", "refs/tags/*") } cmd.AddArguments("--name-only", "--no-undefined").AddDynamicArguments(c.ID.String()) - data, _, err := cmd.RunStdString(c.repo.Ctx, &gitcmd.RunOpts{Dir: c.repo.Path}) + data, _, err := cmd.WithDir(c.repo.Path).RunStdString(c.repo.Ctx) if err != nil { // handle special case where git can not describe commit if strings.Contains(err.Error(), "cannot describe") { @@ -368,89 +268,14 @@ func (c *Commit) GetBranchName() (string, error) { return strings.SplitN(strings.TrimSpace(data), "~", 2)[0], nil } -// CommitFileStatus represents status of files in a commit. -type CommitFileStatus struct { - Added []string - Removed []string - Modified []string -} - -// NewCommitFileStatus creates a CommitFileStatus -func NewCommitFileStatus() *CommitFileStatus { - return &CommitFileStatus{ - []string{}, []string{}, []string{}, - } -} - -func parseCommitFileStatus(fileStatus *CommitFileStatus, stdout io.Reader) { - rd := bufio.NewReader(stdout) - peek, err := rd.Peek(1) - if err != nil { - if err != io.EOF { - log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err) - } - return - } - if peek[0] == '\n' || peek[0] == '\x00' { - _, _ = rd.Discard(1) - } - for { - modifier, err := rd.ReadString('\x00') - if err != nil { - if err != io.EOF { - log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err) - } - return - } - file, err := rd.ReadString('\x00') - if err != nil { - if err != io.EOF { - log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err) - } - return - } - file = file[:len(file)-1] - switch modifier[0] { - case 'A': - fileStatus.Added = append(fileStatus.Added, file) - case 'D': - fileStatus.Removed = append(fileStatus.Removed, file) - case 'M': - fileStatus.Modified = append(fileStatus.Modified, file) - } - } -} - -// GetCommitFileStatus returns file status of commit in given repository. -func GetCommitFileStatus(ctx context.Context, repoPath, commitID string) (*CommitFileStatus, error) { - stdout, w := io.Pipe() - done := make(chan struct{}) - fileStatus := NewCommitFileStatus() - go func() { - parseCommitFileStatus(fileStatus, stdout) - close(done) - }() - - stderr := new(bytes.Buffer) - err := gitcmd.NewCommand("log", "--name-status", "-m", "--pretty=format:", "--first-parent", "--no-renames", "-z", "-1").AddDynamicArguments(commitID).Run(ctx, &gitcmd.RunOpts{ - Dir: repoPath, - Stdout: w, - Stderr: stderr, - }) - w.Close() // Close writer to exit parsing goroutine - if err != nil { - return nil, gitcmd.ConcatenateError(err, stderr.String()) - } - - <-done - return fileStatus, nil -} - // GetFullCommitID returns full length (40) of commit ID by given short SHA in a repository. func GetFullCommitID(ctx context.Context, repoPath, shortID string) (string, error) { - commitID, _, err := gitcmd.NewCommand("rev-parse").AddDynamicArguments(shortID).RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath}) + commitID, _, err := gitcmd.NewCommand("rev-parse"). + AddDynamicArguments(shortID). + WithDir(repoPath). + RunStdString(ctx) if err != nil { - if strings.Contains(err.Error(), "exit status 128") { + if gitcmd.IsErrorExitCode(err, 128) { return "", ErrNotExist{shortID, ""} } return "", err @@ -458,14 +283,6 @@ func GetFullCommitID(ctx context.Context, repoPath, shortID string) (string, err return strings.TrimSpace(commitID), nil } -// GetRepositoryDefaultPublicGPGKey returns the default public key for this commit -func (c *Commit) GetRepositoryDefaultPublicGPGKey(forceUpdate bool) (*GPGSettings, error) { - if c.repo == nil { - return nil, nil - } - return c.repo.GetDefaultPublicGPGKey(forceUpdate) -} - func IsStringLikelyCommitID(objFmt ObjectFormat, s string, minLength ...int) bool { maxLen := 64 // sha256 if objFmt != nil { diff --git a/modules/git/commit_info_test.go b/modules/git/commit_info_test.go index 51e1551d2d3..1e1697b0066 100644 --- a/modules/git/commit_info_test.go +++ b/modules/git/commit_info_test.go @@ -30,28 +30,57 @@ func cloneRepo(tb testing.TB, url string) (string, error) { } func testGetCommitsInfo(t *testing.T, repo1 *Repository) { + type expectedEntryInfo struct { + CommitID string + Size int64 + } + // these test case are specific to the repo1 test repo testCases := []struct { CommitID string Path string - ExpectedIDs map[string]string + ExpectedIDs map[string]expectedEntryInfo ExpectedTreeCommit string }{ - {"8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2", "", map[string]string{ - "file1.txt": "95bb4d39648ee7e325106df01a621c530863a653", - "file2.txt": "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2", + {"8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2", "", map[string]expectedEntryInfo{ + "file1.txt": { + CommitID: "95bb4d39648ee7e325106df01a621c530863a653", + Size: 6, + }, + "file2.txt": { + CommitID: "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2", + Size: 6, + }, }, "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2"}, - {"2839944139e0de9737a044f78b0e4b40d989a9e3", "", map[string]string{ - "file1.txt": "2839944139e0de9737a044f78b0e4b40d989a9e3", - "branch1.txt": "9c9aef8dd84e02bc7ec12641deb4c930a7c30185", + {"2839944139e0de9737a044f78b0e4b40d989a9e3", "", map[string]expectedEntryInfo{ + "file1.txt": { + CommitID: "2839944139e0de9737a044f78b0e4b40d989a9e3", + Size: 15, + }, + "branch1.txt": { + CommitID: "9c9aef8dd84e02bc7ec12641deb4c930a7c30185", + Size: 8, + }, }, "2839944139e0de9737a044f78b0e4b40d989a9e3"}, - {"5c80b0245c1c6f8343fa418ec374b13b5d4ee658", "branch2", map[string]string{ - "branch2.txt": "5c80b0245c1c6f8343fa418ec374b13b5d4ee658", + {"5c80b0245c1c6f8343fa418ec374b13b5d4ee658", "branch2", map[string]expectedEntryInfo{ + "branch2.txt": { + CommitID: "5c80b0245c1c6f8343fa418ec374b13b5d4ee658", + Size: 8, + }, }, "5c80b0245c1c6f8343fa418ec374b13b5d4ee658"}, - {"feaf4ba6bc635fec442f46ddd4512416ec43c2c2", "", map[string]string{ - "file1.txt": "95bb4d39648ee7e325106df01a621c530863a653", - "file2.txt": "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2", - "foo": "37991dec2c8e592043f47155ce4808d4580f9123", + {"feaf4ba6bc635fec442f46ddd4512416ec43c2c2", "", map[string]expectedEntryInfo{ + "file1.txt": { + CommitID: "95bb4d39648ee7e325106df01a621c530863a653", + Size: 6, + }, + "file2.txt": { + CommitID: "8d92fc957a4d7cfd98bc375f0b7bb189a0d6c9f2", + Size: 6, + }, + "foo": { + CommitID: "37991dec2c8e592043f47155ce4808d4580f9123", + Size: 0, + }, }, "feaf4ba6bc635fec442f46ddd4512416ec43c2c2"}, } for _, testCase := range testCases { @@ -93,11 +122,12 @@ func testGetCommitsInfo(t *testing.T, repo1 *Repository) { for _, commitInfo := range commitsInfo { entry := commitInfo.Entry commit := commitInfo.Commit - expectedID, ok := testCase.ExpectedIDs[entry.Name()] + expectedInfo, ok := testCase.ExpectedIDs[entry.Name()] if !assert.True(t, ok) { continue } - assert.Equal(t, expectedID, commit.ID.String()) + assert.Equal(t, expectedInfo.CommitID, commit.ID.String()) + assert.Equal(t, expectedInfo.Size, entry.Size(), entry.Name()) } } } @@ -173,7 +203,6 @@ func BenchmarkEntries_GetCommitsInfo(b *testing.B) { } else if entries, err = commit.Tree.ListEntries(); err != nil { b.Fatal(err) } - entries.Sort() b.ResetTimer() b.Run(benchmark.name, func(b *testing.B) { for b.Loop() { diff --git a/modules/git/commit_sha256_test.go b/modules/git/commit_sha256_test.go index 772f5eedb27..0aefb30c95e 100644 --- a/modules/git/commit_sha256_test.go +++ b/modules/git/commit_sha256_test.go @@ -14,33 +14,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestCommitsCountSha256(t *testing.T) { - bareRepo1Path := filepath.Join(testReposDir, "repo1_bare_sha256") - - commitsCount, err := CommitsCount(t.Context(), - CommitsCountOptions{ - RepoPath: bareRepo1Path, - Revision: []string{"f004f41359117d319dedd0eaab8c5259ee2263da839dcba33637997458627fdc"}, - }) - - assert.NoError(t, err) - assert.Equal(t, int64(3), commitsCount) -} - -func TestCommitsCountWithoutBaseSha256(t *testing.T) { - bareRepo1Path := filepath.Join(testReposDir, "repo1_bare_sha256") - - commitsCount, err := CommitsCount(t.Context(), - CommitsCountOptions{ - RepoPath: bareRepo1Path, - Not: "main", - Revision: []string{"branch1"}, - }) - - assert.NoError(t, err) - assert.Equal(t, int64(2), commitsCount) -} - func TestGetFullCommitIDSha256(t *testing.T) { bareRepo1Path := filepath.Join(testReposDir, "repo1_bare_sha256") @@ -157,39 +130,3 @@ func TestHasPreviousCommitSha256(t *testing.T) { assert.NoError(t, err) assert.False(t, selfNot) } - -func TestGetCommitFileStatusMergesSha256(t *testing.T) { - bareRepo1Path := filepath.Join(testReposDir, "repo6_merge_sha256") - - commitFileStatus, err := GetCommitFileStatus(t.Context(), bareRepo1Path, "d2e5609f630dd8db500f5298d05d16def282412e3e66ed68cc7d0833b29129a1") - assert.NoError(t, err) - - expected := CommitFileStatus{ - []string{ - "add_file.txt", - }, - []string{}, - []string{ - "to_modify.txt", - }, - } - - assert.Equal(t, expected.Added, commitFileStatus.Added) - assert.Equal(t, expected.Removed, commitFileStatus.Removed) - assert.Equal(t, expected.Modified, commitFileStatus.Modified) - - expected = CommitFileStatus{ - []string{}, - []string{ - "to_remove.txt", - }, - []string{}, - } - - commitFileStatus, err = GetCommitFileStatus(t.Context(), bareRepo1Path, "da1ded40dc8e5b7c564171f4bf2fc8370487decfb1cb6a99ef28f3ed73d09172") - assert.NoError(t, err) - - assert.Equal(t, expected.Added, commitFileStatus.Added) - assert.Equal(t, expected.Removed, commitFileStatus.Removed) - assert.Equal(t, expected.Modified, commitFileStatus.Modified) -} diff --git a/modules/git/commit_submodule.go b/modules/git/commit_submodule.go index ff253b7ecab..5e5f90c20e5 100644 --- a/modules/git/commit_submodule.go +++ b/modules/git/commit_submodule.go @@ -16,7 +16,7 @@ func (c *Commit) GetSubModules() (*ObjectCache[*SubModule], error) { entry, err := c.GetTreeEntryByPath(".gitmodules") if err != nil { if _, ok := err.(ErrNotExist); ok { - return nil, nil + return nil, nil //nolint:nilnil // return nil to indicate that the submodule does not exist } return nil, err } @@ -48,5 +48,5 @@ func (c *Commit) GetSubModule(entryName string) (*SubModule, error) { return module, nil } } - return nil, nil + return nil, nil //nolint:nilnil // return nil to indicate that the submodule does not exist } diff --git a/modules/git/commit_test.go b/modules/git/commit_test.go index 688b4e294f5..de7b7455ebb 100644 --- a/modules/git/commit_test.go +++ b/modules/git/commit_test.go @@ -13,33 +13,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestCommitsCount(t *testing.T) { - bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") - - commitsCount, err := CommitsCount(t.Context(), - CommitsCountOptions{ - RepoPath: bareRepo1Path, - Revision: []string{"8006ff9adbf0cb94da7dad9e537e53817f9fa5c0"}, - }) - - assert.NoError(t, err) - assert.Equal(t, int64(3), commitsCount) -} - -func TestCommitsCountWithoutBase(t *testing.T) { - bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") - - commitsCount, err := CommitsCount(t.Context(), - CommitsCountOptions{ - RepoPath: bareRepo1Path, - Not: "master", - Revision: []string{"branch1"}, - }) - - assert.NoError(t, err) - assert.Equal(t, int64(2), commitsCount) -} - func TestGetFullCommitID(t *testing.T) { bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") @@ -212,134 +185,6 @@ func TestHasPreviousCommit(t *testing.T) { assert.False(t, selfNot) } -func TestParseCommitFileStatus(t *testing.T) { - type testcase struct { - output string - added []string - removed []string - modified []string - } - - kases := []testcase{ - { - // Merge commit - output: "MM\x00options/locale/locale_en-US.ini\x00", - modified: []string{ - "options/locale/locale_en-US.ini", - }, - added: []string{}, - removed: []string{}, - }, - { - // Spaces commit - output: "D\x00b\x00D\x00b b/b\x00A\x00b b/b b/b b/b\x00A\x00b b/b b/b b/b b/b\x00", - removed: []string{ - "b", - "b b/b", - }, - modified: []string{}, - added: []string{ - "b b/b b/b b/b", - "b b/b b/b b/b b/b", - }, - }, - { - // larger commit - output: "M\x00go.mod\x00M\x00go.sum\x00M\x00modules/ssh/ssh.go\x00M\x00vendor/github.com/gliderlabs/ssh/circle.yml\x00M\x00vendor/github.com/gliderlabs/ssh/context.go\x00A\x00vendor/github.com/gliderlabs/ssh/go.mod\x00A\x00vendor/github.com/gliderlabs/ssh/go.sum\x00M\x00vendor/github.com/gliderlabs/ssh/server.go\x00M\x00vendor/github.com/gliderlabs/ssh/session.go\x00M\x00vendor/github.com/gliderlabs/ssh/ssh.go\x00M\x00vendor/golang.org/x/sys/unix/mkerrors.sh\x00M\x00vendor/golang.org/x/sys/unix/syscall_darwin.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_darwin_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_darwin_arm64.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_freebsd_386.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_freebsd_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_freebsd_arm.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_freebsd_arm64.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_linux.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_darwin_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_darwin_arm64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_dragonfly_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_freebsd_386.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_freebsd_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_freebsd_arm.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_freebsd_arm64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_netbsd_386.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_netbsd_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_netbsd_arm.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_netbsd_arm64.go\x00M\x00vendor/modules.txt\x00", - modified: []string{ - "go.mod", - "go.sum", - "modules/ssh/ssh.go", - "vendor/github.com/gliderlabs/ssh/circle.yml", - "vendor/github.com/gliderlabs/ssh/context.go", - "vendor/github.com/gliderlabs/ssh/server.go", - "vendor/github.com/gliderlabs/ssh/session.go", - "vendor/github.com/gliderlabs/ssh/ssh.go", - "vendor/golang.org/x/sys/unix/mkerrors.sh", - "vendor/golang.org/x/sys/unix/syscall_darwin.go", - "vendor/golang.org/x/sys/unix/zerrors_darwin_amd64.go", - "vendor/golang.org/x/sys/unix/zerrors_darwin_arm64.go", - "vendor/golang.org/x/sys/unix/zerrors_freebsd_386.go", - "vendor/golang.org/x/sys/unix/zerrors_freebsd_amd64.go", - "vendor/golang.org/x/sys/unix/zerrors_freebsd_arm.go", - "vendor/golang.org/x/sys/unix/zerrors_freebsd_arm64.go", - "vendor/golang.org/x/sys/unix/zerrors_linux.go", - "vendor/golang.org/x/sys/unix/ztypes_darwin_amd64.go", - "vendor/golang.org/x/sys/unix/ztypes_darwin_arm64.go", - "vendor/golang.org/x/sys/unix/ztypes_dragonfly_amd64.go", - "vendor/golang.org/x/sys/unix/ztypes_freebsd_386.go", - "vendor/golang.org/x/sys/unix/ztypes_freebsd_amd64.go", - "vendor/golang.org/x/sys/unix/ztypes_freebsd_arm.go", - "vendor/golang.org/x/sys/unix/ztypes_freebsd_arm64.go", - "vendor/golang.org/x/sys/unix/ztypes_netbsd_386.go", - "vendor/golang.org/x/sys/unix/ztypes_netbsd_amd64.go", - "vendor/golang.org/x/sys/unix/ztypes_netbsd_arm.go", - "vendor/golang.org/x/sys/unix/ztypes_netbsd_arm64.go", - "vendor/modules.txt", - }, - added: []string{ - "vendor/github.com/gliderlabs/ssh/go.mod", - "vendor/github.com/gliderlabs/ssh/go.sum", - }, - removed: []string{}, - }, - { - // git 1.7.2 adds an unnecessary \x00 on merge commit - output: "\x00MM\x00options/locale/locale_en-US.ini\x00", - modified: []string{ - "options/locale/locale_en-US.ini", - }, - added: []string{}, - removed: []string{}, - }, - { - // git 1.7.2 adds an unnecessary \n on normal commit - output: "\nD\x00b\x00D\x00b b/b\x00A\x00b b/b b/b b/b\x00A\x00b b/b b/b b/b b/b\x00", - removed: []string{ - "b", - "b b/b", - }, - modified: []string{}, - added: []string{ - "b b/b b/b b/b", - "b b/b b/b b/b b/b", - }, - }, - } - - for _, kase := range kases { - fileStatus := NewCommitFileStatus() - parseCommitFileStatus(fileStatus, strings.NewReader(kase.output)) - - assert.Equal(t, kase.added, fileStatus.Added) - assert.Equal(t, kase.removed, fileStatus.Removed) - assert.Equal(t, kase.modified, fileStatus.Modified) - } -} - -func TestGetCommitFileStatusMerges(t *testing.T) { - bareRepo1Path := filepath.Join(testReposDir, "repo6_merge") - - commitFileStatus, err := GetCommitFileStatus(t.Context(), bareRepo1Path, "022f4ce6214973e018f02bf363bf8a2e3691f699") - assert.NoError(t, err) - - expected := CommitFileStatus{ - []string{ - "add_file.txt", - }, - []string{ - "to_remove.txt", - }, - []string{ - "to_modify.txt", - }, - } - - assert.Equal(t, expected.Added, commitFileStatus.Added) - assert.Equal(t, expected.Removed, commitFileStatus.Removed) - assert.Equal(t, expected.Modified, commitFileStatus.Modified) -} - func Test_GetCommitBranchStart(t *testing.T) { bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") repo, err := OpenRepository(t.Context(), bareRepo1Path) diff --git a/modules/git/config.go b/modules/git/config.go index 2eafe971b39..79aa0535e4c 100644 --- a/modules/git/config.go +++ b/modules/git/config.go @@ -118,7 +118,9 @@ func syncGitConfig(ctx context.Context) (err error) { } func configSet(ctx context.Context, key, value string) error { - stdout, _, err := gitcmd.NewCommand("config", "--global", "--get").AddDynamicArguments(key).RunStdString(ctx, nil) + stdout, _, err := gitcmd.NewCommand("config", "--global", "--get"). + AddDynamicArguments(key). + RunStdString(ctx) if err != nil && !gitcmd.IsErrorExitCode(err, 1) { return fmt.Errorf("failed to get git config %s, err: %w", key, err) } @@ -128,8 +130,9 @@ func configSet(ctx context.Context, key, value string) error { return nil } - _, _, err = gitcmd.NewCommand("config", "--global").AddDynamicArguments(key, value).RunStdString(ctx, nil) - if err != nil { + if _, _, err = gitcmd.NewCommand("config", "--global"). + AddDynamicArguments(key, value). + RunStdString(ctx); err != nil { return fmt.Errorf("failed to set git global config %s, err: %w", key, err) } @@ -137,14 +140,14 @@ func configSet(ctx context.Context, key, value string) error { } func configSetNonExist(ctx context.Context, key, value string) error { - _, _, err := gitcmd.NewCommand("config", "--global", "--get").AddDynamicArguments(key).RunStdString(ctx, nil) + _, _, err := gitcmd.NewCommand("config", "--global", "--get").AddDynamicArguments(key).RunStdString(ctx) if err == nil { // already exist return nil } if gitcmd.IsErrorExitCode(err, 1) { // not exist, set new config - _, _, err = gitcmd.NewCommand("config", "--global").AddDynamicArguments(key, value).RunStdString(ctx, nil) + _, _, err = gitcmd.NewCommand("config", "--global").AddDynamicArguments(key, value).RunStdString(ctx) if err != nil { return fmt.Errorf("failed to set git global config %s, err: %w", key, err) } @@ -155,14 +158,14 @@ func configSetNonExist(ctx context.Context, key, value string) error { } func configAddNonExist(ctx context.Context, key, value string) error { - _, _, err := gitcmd.NewCommand("config", "--global", "--get").AddDynamicArguments(key, regexp.QuoteMeta(value)).RunStdString(ctx, nil) + _, _, err := gitcmd.NewCommand("config", "--global", "--get").AddDynamicArguments(key, regexp.QuoteMeta(value)).RunStdString(ctx) if err == nil { // already exist return nil } if gitcmd.IsErrorExitCode(err, 1) { // not exist, add new config - _, _, err = gitcmd.NewCommand("config", "--global", "--add").AddDynamicArguments(key, value).RunStdString(ctx, nil) + _, _, err = gitcmd.NewCommand("config", "--global", "--add").AddDynamicArguments(key, value).RunStdString(ctx) if err != nil { return fmt.Errorf("failed to add git global config %s, err: %w", key, err) } @@ -172,10 +175,10 @@ func configAddNonExist(ctx context.Context, key, value string) error { } func configUnsetAll(ctx context.Context, key, value string) error { - _, _, err := gitcmd.NewCommand("config", "--global", "--get").AddDynamicArguments(key).RunStdString(ctx, nil) + _, _, err := gitcmd.NewCommand("config", "--global", "--get").AddDynamicArguments(key).RunStdString(ctx) if err == nil { // exist, need to remove - _, _, err = gitcmd.NewCommand("config", "--global", "--unset-all").AddDynamicArguments(key, regexp.QuoteMeta(value)).RunStdString(ctx, nil) + _, _, err = gitcmd.NewCommand("config", "--global", "--unset-all").AddDynamicArguments(key, regexp.QuoteMeta(value)).RunStdString(ctx) if err != nil { return fmt.Errorf("failed to unset git global config %s, err: %w", key, err) } diff --git a/modules/git/diff.go b/modules/git/diff.go index d185cc92776..d7732eaa29b 100644 --- a/modules/git/diff.go +++ b/modules/git/diff.go @@ -5,71 +5,83 @@ package git import ( "bufio" - "bytes" "context" "fmt" "io" - "os" "regexp" "strconv" "strings" "code.gitea.io/gitea/modules/git/gitcmd" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" ) -// RawDiffType type of a raw diff. +// RawDiffType output format: diff or patch type RawDiffType string -// RawDiffType possible values. const ( RawDiffNormal RawDiffType = "diff" RawDiffPatch RawDiffType = "patch" ) // GetRawDiff dumps diff results of repository in given commit ID to io.Writer. -func GetRawDiff(repo *Repository, commitID string, diffType RawDiffType, writer io.Writer) error { - return GetRepoRawDiffForFile(repo, "", commitID, diffType, "", writer) -} - -// GetReverseRawDiff dumps the reverse diff results of repository in given commit ID to io.Writer. -func GetReverseRawDiff(ctx context.Context, repoPath, commitID string, writer io.Writer) error { - stderr := new(bytes.Buffer) - cmd := gitcmd.NewCommand("show", "--pretty=format:revert %H%n", "-R").AddDynamicArguments(commitID) - if err := cmd.Run(ctx, &gitcmd.RunOpts{ - Dir: repoPath, - Stdout: writer, - Stderr: stderr, - }); err != nil { - return fmt.Errorf("Run: %w - %s", err, stderr) +func GetRawDiff(repo *Repository, commitID string, diffType RawDiffType, writer io.Writer) (retErr error) { + cmd, err := getRepoRawDiffForFileCmd(repo.Ctx, repo, "", commitID, diffType, "") + if err != nil { + return fmt.Errorf("getRepoRawDiffForFileCmd: %w", err) } - return nil + return cmd.WithStdoutCopy(writer).RunWithStderr(repo.Ctx) } -// GetRepoRawDiffForFile dumps diff results of file in given commit ID to io.Writer according given repository -func GetRepoRawDiffForFile(repo *Repository, startCommit, endCommit string, diffType RawDiffType, file string, writer io.Writer) error { +// GetFileDiffCutAroundLine cuts the old or new part of the diff of a file around a specific line number +func GetFileDiffCutAroundLine( + repo *Repository, startCommit, endCommit, treePath string, + line int64, old bool, numbersOfLine int, +) (ret string, retErr error) { + cmd, err := getRepoRawDiffForFileCmd(repo.Ctx, repo, startCommit, endCommit, RawDiffNormal, treePath) + if err != nil { + return "", fmt.Errorf("getRepoRawDiffForFileCmd: %w", err) + } + stdoutReader, stdoutClose := cmd.MakeStdoutPipe() + defer stdoutClose() + cmd.WithPipelineFunc(func(ctx gitcmd.Context) error { + ret, err = CutDiffAroundLine(stdoutReader, line, old, numbersOfLine) + return err + }) + return ret, cmd.RunWithStderr(repo.Ctx) +} + +// getRepoRawDiffForFile returns an io.Reader for the diff results of file in given commit ID +// and a "finish" function to wait for the git command and clean up resources after reading is done. +func getRepoRawDiffForFileCmd(_ context.Context, repo *Repository, startCommit, endCommit string, diffType RawDiffType, file string) (*gitcmd.Command, error) { commit, err := repo.GetCommit(endCommit) if err != nil { - return err + return nil, err } var files []string if len(file) > 0 { files = append(files, file) } - cmd := gitcmd.NewCommand() + cmd := gitcmd.NewCommand().WithDir(repo.Path) switch diffType { case RawDiffNormal: if len(startCommit) != 0 { - cmd.AddArguments("diff", "-M").AddDynamicArguments(startCommit, endCommit).AddDashesAndList(files...) + cmd.AddArguments("diff"). + AddOptionFormat("--find-renames=%s", setting.Git.DiffRenameSimilarityThreshold). + AddDynamicArguments(startCommit, endCommit).AddDashesAndList(files...) } else if commit.ParentCount() == 0 { cmd.AddArguments("show").AddDynamicArguments(endCommit).AddDashesAndList(files...) } else { c, err := commit.Parent(0) if err != nil { - return err + return nil, err } - cmd.AddArguments("diff", "-M").AddDynamicArguments(c.ID.String(), endCommit).AddDashesAndList(files...) + cmd.AddArguments("diff"). + AddOptionFormat("--find-renames=%s", setting.Git.DiffRenameSimilarityThreshold). + AddDynamicArguments(c.ID.String(), endCommit).AddDashesAndList(files...) } case RawDiffPatch: if len(startCommit) != 0 { @@ -80,24 +92,15 @@ func GetRepoRawDiffForFile(repo *Repository, startCommit, endCommit string, diff } else { c, err := commit.Parent(0) if err != nil { - return err + return nil, err } query := fmt.Sprintf("%s...%s", endCommit, c.ID.String()) cmd.AddArguments("format-patch", "--no-signature", "--stdout").AddDynamicArguments(query).AddDashesAndList(files...) } default: - return fmt.Errorf("invalid diffType: %s", diffType) + return nil, util.NewInvalidArgumentErrorf("invalid diff type: %s", diffType) } - - stderr := new(bytes.Buffer) - if err = cmd.Run(repo.Ctx, &gitcmd.RunOpts{ - Dir: repo.Path, - Stdout: writer, - Stderr: stderr, - }); err != nil { - return fmt.Errorf("Run: %w - %s", err, stderr) - } - return nil + return cmd, nil } // ParseDiffHunkString parse the diff hunk content and return @@ -234,7 +237,7 @@ func CutDiffAroundLine(originalDiff io.Reader, line int64, old bool, numbersOfLi } } if err := scanner.Err(); err != nil { - return "", err + return "", fmt.Errorf("CutDiffAroundLine: scan: %w", err) } // No hunk found @@ -300,43 +303,27 @@ func GetAffectedFiles(repo *Repository, branchName, oldCommitID, newCommitID str } oldCommitID = startCommitID } - stdoutReader, stdoutWriter, err := os.Pipe() - if err != nil { - log.Error("Unable to create os.Pipe for %s", repo.Path) - return nil, err - } - defer func() { - _ = stdoutReader.Close() - _ = stdoutWriter.Close() - }() affectedFiles := make([]string, 0, 32) // Run `git diff --name-only` to get the names of the changed files - err = gitcmd.NewCommand("diff", "--name-only").AddDynamicArguments(oldCommitID, newCommitID). - Run(repo.Ctx, &gitcmd.RunOpts{ - Env: env, - Dir: repo.Path, - Stdout: stdoutWriter, - PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error { - // Close the writer end of the pipe to begin processing - _ = stdoutWriter.Close() - defer func() { - // Close the reader on return to terminate the git command if necessary - _ = stdoutReader.Close() - }() - // Now scan the output from the command - scanner := bufio.NewScanner(stdoutReader) - for scanner.Scan() { - path := strings.TrimSpace(scanner.Text()) - if len(path) == 0 { - continue - } - affectedFiles = append(affectedFiles, path) + cmd := gitcmd.NewCommand("diff", "--name-only").AddDynamicArguments(oldCommitID, newCommitID) + stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe() + defer stdoutReaderClose() + err := cmd.WithEnv(env).WithDir(repo.Path). + WithPipelineFunc(func(ctx gitcmd.Context) error { + // Now scan the output from the command + scanner := bufio.NewScanner(stdoutReader) + for scanner.Scan() { + path := strings.TrimSpace(scanner.Text()) + if len(path) == 0 { + continue } - return scanner.Err() - }, - }) + affectedFiles = append(affectedFiles, path) + } + return scanner.Err() + }). + Run(repo.Ctx) if err != nil { log.Error("Unable to get affected files for commits from %s to %s in %s: %v", oldCommitID, newCommitID, repo.Path, err) } diff --git a/modules/git/error.go b/modules/git/error.go index 7d131345d06..1b7bdca0434 100644 --- a/modules/git/error.go +++ b/modules/git/error.go @@ -4,8 +4,6 @@ package git import ( - "context" - "errors" "fmt" "strings" @@ -98,28 +96,31 @@ func (err *ErrPushRejected) Unwrap() error { // GenerateMessage generates the remote message from the stderr func (err *ErrPushRejected) GenerateMessage() { - messageBuilder := &strings.Builder{} - i := strings.Index(err.StdErr, "remote: ") - if i < 0 { - err.Message = "" + // The stderr is like this: + // + // > remote: error: push is rejected ..... + // > To /work/gitea/tests/integration/gitea-integration-sqlite/gitea-repositories/user2/repo1.git + // > ! [remote rejected] 44e67c77559211d21b630b902cdcc6ab9d4a4f51 -> develop (pre-receive hook declined) + // > error: failed to push some refs to '/work/gitea/tests/integration/gitea-integration-sqlite/gitea-repositories/user2/repo1.git' + // + // The local message contains sensitive information, so we only need the remote message + const prefixRemote = "remote: " + const prefixError = "error: " + pos := strings.Index(err.StdErr, prefixRemote) + if pos < 0 { + err.Message = "push is rejected" return } - for { - if len(err.StdErr) <= i+8 { - break - } - if err.StdErr[i:i+8] != "remote: " { - break - } - i += 8 - nl := strings.IndexByte(err.StdErr[i:], '\n') - if nl >= 0 { - messageBuilder.WriteString(err.StdErr[i : i+nl+1]) - i = i + nl + 1 - } else { - messageBuilder.WriteString(err.StdErr[i:]) - i = len(err.StdErr) + + messageBuilder := &strings.Builder{} + lines := strings.SplitSeq(err.StdErr, "\n") + for line := range lines { + line, ok := strings.CutPrefix(line, prefixRemote) + if !ok { + continue } + line = strings.TrimPrefix(line, prefixError) + messageBuilder.WriteString(strings.TrimSpace(line) + "\n") } err.Message = strings.TrimSpace(messageBuilder.String()) } @@ -140,10 +141,3 @@ func IsErrMoreThanOne(err error) bool { func (err *ErrMoreThanOne) Error() string { return fmt.Sprintf("ErrMoreThanOne Error: %v: %s\n%s", err.Err, err.StdErr, err.StdOut) } - -func IsErrCanceledOrKilled(err error) bool { - // When "cancel()" a git command's context, the returned error of "Run()" could be one of them: - // - context.Canceled - // - *exec.ExitError: "signal: killed" - return err != nil && (errors.Is(err, context.Canceled) || err.Error() == "signal: killed") -} diff --git a/modules/git/foreachref/format.go b/modules/git/foreachref/format.go index d9573a55d62..87c1c9a4ff9 100644 --- a/modules/git/foreachref/format.go +++ b/modules/git/foreachref/format.go @@ -53,7 +53,7 @@ func (f Format) Flag() string { var formatFlag strings.Builder for i, field := range f.fieldNames { // field key and field value - formatFlag.WriteString(fmt.Sprintf("%s %%(%s)", field, field)) + fmt.Fprintf(&formatFlag, "%s %%(%s)", field, field) if i < len(f.fieldNames)-1 { // note: escape delimiters to allow control characters as @@ -72,12 +72,12 @@ func (f Format) Parser(r io.Reader) *Parser { return NewParser(r, f) } -// hexEscaped produces hex-escpaed characters from a string. For example, "\n\0" +// hexEscaped produces hex-escaped characters from a string. For example, "\n\0" // would turn into "%0a%00". func (f Format) hexEscaped(delim []byte) string { - escaped := "" + var escaped strings.Builder for i := range delim { - escaped += "%" + hex.EncodeToString([]byte{delim[i]}) + escaped.WriteString("%" + hex.EncodeToString([]byte{delim[i]})) } - return escaped + return escaped.String() } diff --git a/modules/git/foreachref/parser.go b/modules/git/foreachref/parser.go index ebdc7344d0c..91868076b42 100644 --- a/modules/git/foreachref/parser.go +++ b/modules/git/foreachref/parser.go @@ -30,9 +30,11 @@ type Parser struct { func NewParser(r io.Reader, format Format) *Parser { scanner := bufio.NewScanner(r) - // default MaxScanTokenSize = 64 kiB may be too small for some references, - // so allow the buffer to grow up to 4x if needed - scanner.Buffer(nil, 4*bufio.MaxScanTokenSize) + // default Scanner.MaxScanTokenSize = 64 kiB may be too small for some references, + // so allow the buffer to be large enough in case the ref has long content (e.g.: a tag with long message) + // as long as it doesn't exceed some reasonable limit (4 MiB here, or MAX_DISPLAY_FILE_SIZE=8MiB), it is OK + // there are still some choices: 1. add a config option for the limit; 2. don't use scanner and write our own parser to fully handle large contents + scanner.Buffer(nil, 4*1024*1024) // in addition to the reference delimiter we specified in the --format, // `git for-each-ref` will always add a newline after every reference. @@ -98,7 +100,7 @@ func (p *Parser) Err() error { func (p *Parser) parseRef(refBlock string) (map[string]string, error) { if refBlock == "" { // must be at EOF - return nil, nil + return nil, nil //nolint:nilnil // return nil to signal EOF } fieldValues := make(map[string]string) @@ -113,10 +115,10 @@ func (p *Parser) parseRef(refBlock string) (map[string]string, error) { var fieldKey string var fieldVal string - firstSpace := strings.Index(field, " ") - if firstSpace > 0 { - fieldKey = field[:firstSpace] - fieldVal = field[firstSpace+1:] + before, after, ok := strings.Cut(field, " ") + if ok { + fieldKey = before + fieldVal = after } else { // could be the case if the requested field had no value fieldKey = field diff --git a/modules/git/git.go b/modules/git/git.go index 161fa42196a..2df83f9843a 100644 --- a/modules/git/git.go +++ b/modules/git/git.go @@ -12,25 +12,28 @@ import ( "path/filepath" "runtime" "strings" - "time" "code.gitea.io/gitea/modules/git/gitcmd" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/tempdir" + "code.gitea.io/gitea/modules/testlogger" "github.com/hashicorp/go-version" ) -const RequiredVersion = "2.0.0" // the minimum Git version required +const RequiredVersion = "2.6.0" // the minimum Git version required type Features struct { gitVersion *version.Version - UsingGogit bool - SupportProcReceive bool // >= 2.29 - SupportHashSha256 bool // >= 2.42, SHA-256 repositories no longer an ‘experimental curiosity’ - SupportedObjectFormats []ObjectFormat // sha1, sha256 - SupportCheckAttrOnBare bool // >= 2.40 + UsingGogit bool + SupportProcReceive bool // >= 2.29 + SupportHashSha256 bool // >= 2.42, SHA-256 repositories no longer an ‘experimental curiosity’ + SupportedObjectFormats []ObjectFormat // sha1, sha256 + SupportCheckAttrOnBare bool // >= 2.40 + SupportCatFileBatchCommand bool // >= 2.36, support `git cat-file --batch-command` + SupportGitMergeTree bool // >= 2.40 // we also need "--merge-base" } var defaultFeatures *Features @@ -57,7 +60,7 @@ func DefaultFeatures() *Features { } func loadGitVersionFeatures() (*Features, error) { - stdout, _, runErr := gitcmd.NewCommand("version").RunStdString(context.Background(), nil) + stdout, _, runErr := gitcmd.NewCommand("version").RunStdString(context.Background()) if runErr != nil { return nil, runErr } @@ -75,6 +78,8 @@ func loadGitVersionFeatures() (*Features, error) { features.SupportedObjectFormats = append(features.SupportedObjectFormats, Sha256ObjectFormat) } features.SupportCheckAttrOnBare = features.CheckVersionAtLeast("2.40") + features.SupportCatFileBatchCommand = features.CheckVersionAtLeast("2.36") + features.SupportGitMergeTree = features.CheckVersionAtLeast("2.40") // we also need "--merge-base" return features, nil } @@ -84,12 +89,17 @@ func parseGitVersionLine(s string) (*version.Version, error) { return nil, fmt.Errorf("invalid git version: %q", s) } - // version string is like: "git version 2.29.3" or "git version 2.29.3.windows.1" + // version output is like: "git version {versionString}" + // versionString can be: + // * "2.5.3" + // * "2.29.3.windows.1" + // * "2.28.0.618.gf4bc123cb7": https://github.com/go-gitea/gitea/issues/12731 versionString := fields[2] - if pos := strings.Index(versionString, "windows"); pos >= 1 { - versionString = versionString[:pos-1] + versionFields := strings.Split(versionString, ".") + if len(versionFields) > 3 { + versionFields = versionFields[:3] } - return version.NewVersion(versionString) + return version.NewVersion(strings.Join(versionFields, ".")) } func checkGitVersionCompatibility(gitVer *version.Version) error { @@ -137,10 +147,6 @@ func InitSimple() error { log.Warn("git module has been initialized already, duplicate init may work but it's better to fix it") } - if setting.Git.Timeout.Default > 0 { - gitcmd.SetDefaultCommandExecutionTimeout(time.Duration(setting.Git.Timeout.Default) * time.Second) - } - if err := gitcmd.SetExecutablePath(setting.Git.Path); err != nil { return err } @@ -176,3 +182,23 @@ func InitFull() (err error) { return syncGitConfig(context.Background()) } + +// RunGitTests helps to init the git module and run tests. +// FIXME: GIT-PACKAGE-DEPENDENCY: the dependency is not right, setting.Git.HomePath is initialized in this package but used in gitcmd package +func RunGitTests(m interface{ Run() int }) { + os.Exit(runGitTests(m)) +} + +func runGitTests(m interface{ Run() int }) int { + gitHomePath, cleanup, err := tempdir.OsTempDir("gitea-test").MkdirTempRandom("git-home") + if err != nil { + testlogger.Panicf("unable to create temp dir: %s", err.Error()) + } + defer cleanup() + + setting.Git.HomePath = gitHomePath + if err = InitFull(); err != nil { + testlogger.Panicf("failed to call Init: %s", err.Error()) + } + return m.Run() +} diff --git a/modules/git/git_test.go b/modules/git/git_test.go index 7a8ca74b015..e21cbe449a4 100644 --- a/modules/git/git_test.go +++ b/modules/git/git_test.go @@ -4,42 +4,14 @@ package git import ( - "fmt" - "os" "testing" - "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/tempdir" - "github.com/hashicorp/go-version" "github.com/stretchr/testify/assert" ) -func testRun(m *testing.M) error { - gitHomePath, cleanup, err := tempdir.OsTempDir("gitea-test").MkdirTempRandom("git-home") - if err != nil { - return fmt.Errorf("unable to create temp dir: %w", err) - } - defer cleanup() - - setting.Git.HomePath = gitHomePath - - if err = InitFull(); 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) - } + RunGitTests(m) } func TestParseGitVersion(t *testing.T) { @@ -51,6 +23,10 @@ func TestParseGitVersion(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "2.29.3", v.String()) + v, err = parseGitVersionLine("git version 2.28.0.618.gf4bc123cb7") + assert.NoError(t, err) + assert.Equal(t, "2.28.0", v.String()) + _, err = parseGitVersionLine("git version") assert.Error(t, err) diff --git a/modules/git/gitcmd/command.go b/modules/git/gitcmd/command.go index 131bf9d8366..e0393799a2c 100644 --- a/modules/git/gitcmd/command.go +++ b/modules/git/gitcmd/command.go @@ -13,7 +13,6 @@ import ( "os" "os/exec" "path/filepath" - "runtime" "strings" "time" @@ -29,23 +28,32 @@ import ( // In most cases, it shouldn't be used. Use AddXxx function instead type TrustedCmdArgs []internal.CmdArg -// defaultCommandExecutionTimeout default command execution timeout duration -var defaultCommandExecutionTimeout = 360 * time.Second - -func SetDefaultCommandExecutionTimeout(timeout time.Duration) { - defaultCommandExecutionTimeout = timeout -} - -// DefaultLocale is the default LC_ALL to run git commands in. -const DefaultLocale = "C" - // Command represents a command with its subcommands or arguments. type Command struct { + callerInfo string prog string args []string - brokenArgs []string - cmd *exec.Cmd // for debug purpose only + preErrors []error configArgs []string + opts runOpts + + cmd *exec.Cmd + + cmdCtx context.Context + cmdCancel process.CancelCauseFunc + cmdFinished process.FinishedFunc + cmdStartTime time.Time + + parentPipeFiles []*os.File + parentPipeReaders []*os.File + childrenPipeFiles []*os.File + + // only os.Pipe and in-memory buffers can work with Stdin safely, see https://github.com/golang/go/issues/77227 if the command would exit unexpectedly + cmdStdin io.Reader + cmdStdout io.Writer + cmdStderr io.Writer + + cmdManagedStderr *bytes.Buffer } func logArgSanitize(arg string) string { @@ -96,6 +104,10 @@ func NewCommand(args ...internal.CmdArg) *Command { } } +func (c *Command) handlePreErrorBrokenCommand(arg string) { + c.preErrors = append(c.preErrors, util.ErrorWrap(ErrBrokenCommand, `broken git command argument %q`, arg)) +} + // isSafeArgumentValue checks if the argument is safe to be used as a value (not an option) func isSafeArgumentValue(s string) bool { return s == "" || s[0] != '-' @@ -123,7 +135,7 @@ func (c *Command) AddArguments(args ...internal.CmdArg) *Command { // The values are treated as dynamic argument values. It equals to: AddArguments("--opt") then AddDynamicArguments(val). func (c *Command) AddOptionValues(opt internal.CmdArg, args ...string) *Command { if !isValidArgumentOption(string(opt)) { - c.brokenArgs = append(c.brokenArgs, string(opt)) + c.handlePreErrorBrokenCommand(string(opt)) return c } c.args = append(c.args, string(opt)) @@ -135,12 +147,12 @@ func (c *Command) AddOptionValues(opt internal.CmdArg, args ...string) *Command // For example: AddOptionFormat("--opt=%s %s", val1, val2) means 1 argument: {"--opt=val1 val2"}. func (c *Command) AddOptionFormat(opt string, args ...any) *Command { if !isValidArgumentOption(opt) { - c.brokenArgs = append(c.brokenArgs, opt) + c.handlePreErrorBrokenCommand(opt) return c } // a quick check to make sure the format string matches the number of arguments, to find low-level mistakes ASAP if strings.Count(strings.ReplaceAll(opt, "%%", ""), "%") != len(args) { - c.brokenArgs = append(c.brokenArgs, opt) + c.handlePreErrorBrokenCommand(opt) return c } s := fmt.Sprintf(opt, args...) @@ -154,10 +166,10 @@ func (c *Command) AddOptionFormat(opt string, args ...any) *Command { func (c *Command) AddDynamicArguments(args ...string) *Command { for _, arg := range args { if !isSafeArgumentValue(arg) { - c.brokenArgs = append(c.brokenArgs, arg) + c.handlePreErrorBrokenCommand(arg) } } - if len(c.brokenArgs) != 0 { + if len(c.preErrors) != 0 { return c } c.args = append(c.args, args...) @@ -177,7 +189,7 @@ func (c *Command) AddDashesAndList(list ...string) *Command { func (c *Command) AddConfig(key, value string) *Command { kv := key + "=" + value if !isSafeArgumentValue(kv) { - c.brokenArgs = append(c.brokenArgs, key) + c.handlePreErrorBrokenCommand(kv) } else { c.configArgs = append(c.configArgs, "-c", kv) } @@ -194,11 +206,9 @@ func ToTrustedCmdArgs(args []string) TrustedCmdArgs { return ret } -// RunOpts represents parameters to run the command. If UseContextTimeout is specified, then Timeout is ignored. -type RunOpts struct { - Env []string - Timeout time.Duration - UseContextTimeout bool +type runOpts struct { + Env []string + Timeout time.Duration // Dir is the working dir for the git command, however: // FIXME: this could be incorrect in many cases, for example: @@ -208,23 +218,11 @@ type RunOpts struct { // The correct approach is to use `--git-dir" global argument Dir string - Stdout, Stderr io.Writer - - // Stdin is used for passing input to the command - // The caller must make sure the Stdin writer is closed properly to finish the Run function. - // Otherwise, the Run function may hang for long time or forever, especially when the Git's context deadline is not the same as the caller's. - // Some common mistakes: - // * `defer stdinWriter.Close()` then call `cmd.Run()`: the Run() would never return if the command is killed by timeout - // * `go { case <- parentContext.Done(): stdinWriter.Close() }` with `cmd.Run(DefaultTimeout)`: the command would have been killed by timeout but the Run doesn't return until stdinWriter.Close() - // * `go { if stdoutReader.Read() err != nil: stdinWriter.Close() }` with `cmd.Run()`: the stdoutReader may never return error if the command is killed by timeout - // In the future, ideally the git module itself should have full control of the stdin, to avoid such problems and make it easier to refactor to a better architecture. - Stdin io.Reader - // SSHAuthSock is the path to an SSH agent socket for authentication // If provided, SSH_AUTH_SOCK environment variable will be set SSHAuthSock string - PipelineFunc func(context.Context, context.CancelFunc) error + PipelineFunc func(Context) error } func commonBaseEnvs() []string { @@ -255,7 +253,7 @@ func commonBaseEnvs() []string { // CommonGitCmdEnvs returns the common environment variables for a "git" command. func CommonGitCmdEnvs() []string { return append(commonBaseEnvs(), []string{ - "LC_ALL=" + DefaultLocale, + "LC_ALL=C", // ensure git output is in English, error messages are parsed in English "GIT_TERMINAL_PROMPT=0", // avoid prompting for credentials interactively, supported since git v2.3 }...) } @@ -267,191 +265,330 @@ func CommonCmdServEnvs() []string { var ErrBrokenCommand = errors.New("git command is broken") -// Run runs the command with the RunOpts -func (c *Command) Run(ctx context.Context, opts *RunOpts) error { - return c.run(ctx, 1, opts) +func (c *Command) WithDir(dir string) *Command { + c.opts.Dir = dir + return c } -func (c *Command) run(ctx context.Context, skip int, opts *RunOpts) error { - if len(c.brokenArgs) != 0 { - log.Error("git command is broken: %s, broken args: %s", c.LogString(), strings.Join(c.brokenArgs, " ")) - return ErrBrokenCommand - } - if opts == nil { - opts = &RunOpts{} - } +func (c *Command) WithEnv(env []string) *Command { + c.opts.Env = env + return c +} - // We must not change the provided options - timeout := opts.Timeout - if timeout <= 0 { - timeout = defaultCommandExecutionTimeout - } +func (c *Command) WithTimeout(timeout time.Duration) *Command { + c.opts.Timeout = timeout + return c +} - cmdLogString := c.LogString() - callerInfo := util.CallerFuncName(1 /* util */ + 1 /* this */ + skip /* parent */) +func (c *Command) WithSSHAuthSock(sshAuthSock string) *Command { + c.opts.SSHAuthSock = sshAuthSock + return c +} + +func (c *Command) makeStdoutStderr(w *io.Writer) (PipeReader, func()) { + pr, pw, err := os.Pipe() + if err != nil { + c.preErrors = append(c.preErrors, err) + return &pipeNull{err}, func() {} + } + c.childrenPipeFiles = append(c.childrenPipeFiles, pw) + c.parentPipeFiles = append(c.parentPipeFiles, pr) + c.parentPipeReaders = append(c.parentPipeReaders, pr) + *w /* stdout, stderr */ = pw + return &pipeReader{f: pr}, func() { pr.Close() } +} + +// MakeStdinPipe creates a writer for the command's stdin. +// The returned closer function must be called by the caller to close the pipe. +func (c *Command) MakeStdinPipe() (writer PipeWriter, closer func()) { + pr, pw, err := os.Pipe() + if err != nil { + c.preErrors = append(c.preErrors, err) + return &pipeNull{err}, func() {} + } + c.childrenPipeFiles = append(c.childrenPipeFiles, pr) + c.parentPipeFiles = append(c.parentPipeFiles, pw) + c.cmdStdin = pr + return &pipeWriter{pw}, func() { pw.Close() } +} + +// MakeStdoutPipe creates a reader for the command's stdout. +// The returned closer function must be called by the caller to close the pipe. +// After the pipe reader is closed, the unread data will be discarded. +// +// If the process (git command) still tries to write after the pipe is closed, the Wait error will be "signal: broken pipe". +// WithPipelineFunc + Run won't return "broken pipe" error in this case if the callback returns no error. +// But if you are calling Start / Wait family functions, you should either drain the pipe before close it, or handle the Wait error correctly. +func (c *Command) MakeStdoutPipe() (reader PipeReader, closer func()) { + return c.makeStdoutStderr(&c.cmdStdout) +} + +// MakeStderrPipe is like MakeStdoutPipe, but for stderr. +func (c *Command) MakeStderrPipe() (reader PipeReader, closer func()) { + return c.makeStdoutStderr(&c.cmdStderr) +} + +func (c *Command) MakeStdinStdoutPipe() (stdin PipeWriter, stdout PipeReader, closer func()) { + stdin, stdinClose := c.MakeStdinPipe() + stdout, stdoutClose := c.MakeStdoutPipe() + return stdin, stdout, func() { + stdinClose() + stdoutClose() + } +} + +func (c *Command) WithStdinBytes(stdin []byte) *Command { + c.cmdStdin = bytes.NewReader(stdin) + return c +} + +func (c *Command) WithStdoutBuffer(w PipeBufferWriter) *Command { + c.cmdStdout = w + return c +} + +// WithStdinCopy and WithStdoutCopy are general functions that accept any io.Reader / io.Writer. +// In this case, Golang exec.Cmd will start new internal goroutines to do io.Copy between pipes and provided Reader/Writer. +// If the reader or writer is blocked and never returns, then the io.Copy won't finish, then exec.Cmd.Wait won't return, which may cause deadlocks. +// A typical deadlock example is: +// * `r,w:=io.Pipe(); cmd.Stdin=r; defer w.Close(); cmd.Run()`: the Run() will never return because stdin reader is blocked forever and w.Close() will never be called. +// If the reader/writer won't block forever (for example: read from a file or buffer), then these functions are safe to use. +func (c *Command) WithStdinCopy(w io.Reader) *Command { + c.cmdStdin = w + return c +} + +func (c *Command) WithStdoutCopy(w io.Writer) *Command { + c.cmdStdout = w + return c +} + +// WithPipelineFunc sets the pipeline function for the command. +// The pipeline function will be called in the Run / Wait function after the command is started successfully. +// The function can read/write from/to the command's stdio pipes (if any). +// The pipeline function can cancel (kill) the command by calling ctx.CancelPipeline before the command finishes. +// The returned error of Run / Wait can be joined errors from the pipeline function, context cause, and command exit error. +// Caller can get the pipeline function's error (if any) by UnwrapPipelineError. +func (c *Command) WithPipelineFunc(f func(ctx Context) error) *Command { + c.opts.PipelineFunc = f + return c +} + +// WithParentCallerInfo can be used to set the caller info (usually function name) of the parent function of the caller. +// For most cases, "Run" family functions can get its caller info automatically +// But if you need to call "Run" family functions in a wrapper function: "FeatureFunc -> GeneralWrapperFunc -> RunXxx", +// then you can to call this function in GeneralWrapperFunc to set the caller info of FeatureFunc. +// The caller info can only be set once. +func (c *Command) WithParentCallerInfo(optInfo ...string) *Command { + if c.callerInfo != "" { + return c + } + if len(optInfo) > 0 { + c.callerInfo = optInfo[0] + return c + } + skip := 1 /*parent "wrap/run" functions*/ + 1 /*this function*/ + callerFuncName := util.CallerFuncName(skip) + callerInfo := callerFuncName if pos := strings.LastIndex(callerInfo, "/"); pos >= 0 { callerInfo = callerInfo[pos+1:] } + c.callerInfo = callerInfo + return c +} + +func (c *Command) Start(ctx context.Context) (retErr error) { + if c.cmd != nil { + // this is a programming error, it will cause serious deadlock problems, so it must be fixed. + panic("git command has already been started") + } + + defer func() { + c.closePipeFiles(c.childrenPipeFiles) + if retErr != nil { + // release the pipes to avoid resource leak since the command failed to start + c.closePipeFiles(c.parentPipeFiles) + // if error occurs, we must also finish the task, otherwise, cmdFinished will be called in "Wait" function + if c.cmdFinished != nil { + c.cmdFinished() + } + } + }() + + if len(c.preErrors) != 0 { + // In most cases, such error shouldn't happen. If it happens, log it as error level with more details + err := errors.Join(c.preErrors...) + log.Error("git command: %s, error: %s", c.LogString(), err) + return err + } + + cmdLogString := c.LogString() + if c.callerInfo == "" { + c.WithParentCallerInfo() + } // these logs are for debugging purposes only, so no guarantee of correctness or stability - desc := fmt.Sprintf("git.Run(by:%s, repo:%s): %s", callerInfo, logArgSanitize(opts.Dir), cmdLogString) + desc := fmt.Sprintf("git.Run(by:%s, repo:%s): %s", c.callerInfo, logArgSanitize(c.opts.Dir), cmdLogString) log.Debug("git.Command: %s", desc) _, span := gtprof.GetTracer().Start(ctx, gtprof.TraceSpanGitRun) defer span.End() - span.SetAttributeString(gtprof.TraceAttrFuncCaller, callerInfo) + span.SetAttributeString(gtprof.TraceAttrFuncCaller, c.callerInfo) span.SetAttributeString(gtprof.TraceAttrGitCommand, cmdLogString) - var cancel context.CancelFunc - var finished context.CancelFunc - - if opts.UseContextTimeout { - ctx, cancel, finished = process.GetManager().AddContext(ctx, desc) + if c.opts.Timeout <= 0 { + c.cmdCtx, c.cmdCancel, c.cmdFinished = process.GetManager().AddContext(ctx, desc) } else { - ctx, cancel, finished = process.GetManager().AddContextTimeout(ctx, timeout, desc) + c.cmdCtx, c.cmdCancel, c.cmdFinished = process.GetManager().AddContextTimeout(ctx, c.opts.Timeout, desc) } - defer finished() - startTime := time.Now() + c.cmdStartTime = time.Now() - cmd := exec.CommandContext(ctx, c.prog, append(c.configArgs, c.args...)...) - c.cmd = cmd // for debug purpose only - if opts.Env == nil { - cmd.Env = os.Environ() + c.cmd = exec.CommandContext(c.cmdCtx, c.prog, append(c.configArgs, c.args...)...) + if c.opts.Env == nil { + c.cmd.Env = os.Environ() } else { - cmd.Env = opts.Env + c.cmd.Env = c.opts.Env } - process.SetSysProcAttribute(cmd) - cmd.Env = append(cmd.Env, CommonGitCmdEnvs()...) + process.SetSysProcAttribute(c.cmd) + c.cmd.Env = append(c.cmd.Env, CommonGitCmdEnvs()...) - if opts.SSHAuthSock != "" { - cmd.Env = append(cmd.Env, "SSH_AUTH_SOCK="+opts.SSHAuthSock) + if c.opts.SSHAuthSock != "" { + c.cmd.Env = append(c.cmd.Env, "SSH_AUTH_SOCK="+c.opts.SSHAuthSock) } - cmd.Dir = opts.Dir - cmd.Stdout = opts.Stdout - cmd.Stderr = opts.Stderr - cmd.Stdin = opts.Stdin - if err := cmd.Start(); err != nil { - return err - } + c.cmd.Dir = c.opts.Dir + c.cmd.Stdout = c.cmdStdout + c.cmd.Stdin = c.cmdStdin + c.cmd.Stderr = c.cmdStderr + return c.cmd.Start() +} - if opts.PipelineFunc != nil { - err := opts.PipelineFunc(ctx, cancel) - if err != nil { - cancel() - _ = cmd.Wait() - return err +func (c *Command) closePipeFiles(files []*os.File) { + for _, f := range files { + _ = f.Close() + } +} + +func (c *Command) discardPipeReaders(files []*os.File) { + for _, f := range files { + _, _ = io.Copy(io.Discard, f) + } +} + +func (c *Command) Wait() error { + defer func() { + // The reader in another goroutine might be still reading the stdout, so we shouldn't close the pipes here + // MakeStdoutPipe returns a closer function to force callers to close the pipe correctly + // Here we only need to mark the command as finished + c.cmdFinished() + }() + + if c.opts.PipelineFunc != nil { + errPipeline := c.opts.PipelineFunc(&cmdContext{Context: c.cmdCtx, cmd: c}) + + if context.Cause(c.cmdCtx) == nil { + // if the context is not canceled explicitly, we need to discard the unread data, + // and wait for the command to exit normally, and then get its exit code + c.discardPipeReaders(c.parentPipeReaders) + } // else: canceled command will be killed, and the exit code is caused by kill + + // after the pipeline function returns, we can safely close the pipes, then wait for the command to exit + c.closePipeFiles(c.parentPipeFiles) + errWait := c.cmd.Wait() + errCause := context.Cause(c.cmdCtx) // in case the cause is set during Wait(), get the final cancel cause + + if unwrapped, ok := UnwrapPipelineError(errCause); ok { + if unwrapped != errPipeline { + panic("unwrapped context pipeline error should be the same one returned by pipeline function") + } + if unwrapped == nil { + // the pipeline function declares that there is no error, and it cancels (kills) the command ahead, + // so we should ignore the errors from "wait" and "cause" + errWait, errCause = nil, nil + } } + + // some legacy code still need to access the error returned by pipeline function by "==" but not "errors.Is" + // so we need to make sure the original error is able to be unwrapped by UnwrapPipelineError + return errors.Join(wrapPipelineError(errPipeline), errCause, errWait) } - err := cmd.Wait() - elapsed := time.Since(startTime) + // there might be other goroutines using the context or pipes, so we just wait for the command to finish + errWait := c.cmd.Wait() + elapsed := time.Since(c.cmdStartTime) if elapsed > time.Second { - log.Debug("slow git.Command.Run: %s (%s)", c, elapsed) + log.Debug("slow git.Command.Run: %s (%s)", c, elapsed) // TODO: no need to log this for long-running commands } - // We need to check if the context is canceled by the program on Windows. - // This is because Windows does not have signal checking when terminating the process. - // It always returns exit code 1, unlike Linux, which has many exit codes for signals. - // `err.Error()` returns "exit status 1" when using the `git check-attr` command after the context is canceled. - if runtime.GOOS == "windows" && - err != nil && - (err.Error() == "" || err.Error() == "exit status 1") && - cmd.ProcessState.ExitCode() == 1 && - ctx.Err() == context.Canceled { - return ctx.Err() - } + // Here the logic is different from "PipelineFunc" case, + // because PipelineFunc can return error if it fails, it knows whether it succeeds or fails. + // But in normal case, the caller just runs the git command, the command's exit code is the source of truth. + // If the caller need to know whether the command error is caused by cancellation, it should check the "err" by itself. + errCause := context.Cause(c.cmdCtx) + return errors.Join(errCause, errWait) +} - if err != nil && ctx.Err() != context.DeadlineExceeded { +func (c *Command) StartWithStderr(ctx context.Context) RunStdError { + if c.cmdStderr != nil { + panic("caller-provided stderr receiver doesn't work with managed stderr buffer") + } + c.cmdManagedStderr = &bytes.Buffer{} + c.cmdStderr = c.cmdManagedStderr + err := c.Start(ctx) + if err != nil { + return &runStdError{err: err} + } + return nil +} + +func (c *Command) WaitWithStderr() RunStdError { + if c.cmdManagedStderr == nil { + panic("managed stderr buffer is not initialized") + } + errWait := c.Wait() + if errWait == nil { + // if no exec error but only stderr output, the stderr output is still saved in "c.cmdManagedStderr" and can be read later + return nil + } + return &runStdError{err: errWait, stderr: util.UnsafeBytesToString(c.cmdManagedStderr.Bytes())} +} + +func (c *Command) RunWithStderr(ctx context.Context) RunStdError { + if err := c.StartWithStderr(ctx); err != nil { + return &runStdError{err: err} + } + return c.WaitWithStderr() +} + +func (c *Command) Run(ctx context.Context) (err error) { + if err = c.Start(ctx); err != nil { return err } - - return ctx.Err() + return c.Wait() } -type RunStdError interface { - error - Unwrap() error - Stderr() string +// RunStdString runs the command and returns stdout/stderr as string. and store stderr to returned error (err combined with stderr). +func (c *Command) RunStdString(ctx context.Context) (stdout, stderr string, runErr RunStdError) { + stdoutBytes, stderrBytes, runErr := c.WithParentCallerInfo().runStdBytes(ctx) + return util.UnsafeBytesToString(stdoutBytes), util.UnsafeBytesToString(stderrBytes), runErr } -type runStdError struct { - err error - stderr string - errMsg string +// RunStdBytes runs the command and returns stdout/stderr as bytes. and store stderr to returned error (err combined with stderr). +func (c *Command) RunStdBytes(ctx context.Context) (stdout, stderr []byte, runErr RunStdError) { + return c.WithParentCallerInfo().runStdBytes(ctx) } -func (r *runStdError) Error() string { - // the stderr must be in the returned error text, some code only checks `strings.Contains(err.Error(), "git error")` - if r.errMsg == "" { - r.errMsg = ConcatenateError(r.err, r.stderr).Error() - } - return r.errMsg -} - -func (r *runStdError) Unwrap() error { - return r.err -} - -func (r *runStdError) Stderr() string { - return r.stderr -} - -func IsErrorExitCode(err error, code int) bool { - var exitError *exec.ExitError - if errors.As(err, &exitError) { - return exitError.ExitCode() == code - } - return false -} - -// RunStdString runs the command with options and returns stdout/stderr as string. and store stderr to returned error (err combined with stderr). -func (c *Command) RunStdString(ctx context.Context, opts *RunOpts) (stdout, stderr string, runErr RunStdError) { - stdoutBytes, stderrBytes, err := c.runStdBytes(ctx, opts) - stdout = util.UnsafeBytesToString(stdoutBytes) - stderr = util.UnsafeBytesToString(stderrBytes) - if err != nil { - return stdout, stderr, &runStdError{err: err, stderr: stderr} - } - // even if there is no err, there could still be some stderr output, so we just return stdout/stderr as they are - return stdout, stderr, nil -} - -// RunStdBytes runs the command with options and returns stdout/stderr as bytes. and store stderr to returned error (err combined with stderr). -func (c *Command) RunStdBytes(ctx context.Context, opts *RunOpts) (stdout, stderr []byte, runErr RunStdError) { - return c.runStdBytes(ctx, opts) -} - -func (c *Command) runStdBytes(ctx context.Context, opts *RunOpts) (stdout, stderr []byte, runErr RunStdError) { - if opts == nil { - opts = &RunOpts{} - } - if opts.Stdout != nil || opts.Stderr != nil { - // we must panic here, otherwise there would be bugs if developers set Stdin/Stderr by mistake, and it would be very difficult to debug +func (c *Command) runStdBytes(ctx context.Context) ([]byte, []byte, RunStdError) { + if c.cmdStdout != nil || c.cmdStderr != nil { + // it must panic here, otherwise there would be bugs if developers set other Stdin/Stderr by mistake, and it would be very difficult to debug panic("stdout and stderr field must be nil when using RunStdBytes") } stdoutBuf := &bytes.Buffer{} - stderrBuf := &bytes.Buffer{} - - // We must not change the provided options as it could break future calls - therefore make a copy. - newOpts := &RunOpts{ - Env: opts.Env, - Timeout: opts.Timeout, - UseContextTimeout: opts.UseContextTimeout, - Dir: opts.Dir, - Stdout: stdoutBuf, - Stderr: stderrBuf, - Stdin: opts.Stdin, - SSHAuthSock: opts.SSHAuthSock, - PipelineFunc: opts.PipelineFunc, - } - - err := c.run(ctx, 2, newOpts) - stderr = stderrBuf.Bytes() - if err != nil { - return nil, stderr, &runStdError{err: err, stderr: util.UnsafeBytesToString(stderr)} - } - // even if there is no err, there could still be some stderr output - return stdoutBuf.Bytes(), stderr, nil + err := c.WithParentCallerInfo().WithStdoutBuffer(stdoutBuf).RunWithStderr(ctx) + return stdoutBuf.Bytes(), c.cmdManagedStderr.Bytes(), err +} + +func (c *Command) DebugKill() { + _ = c.cmd.Process.Kill() } diff --git a/modules/git/gitcmd/command_race_test.go b/modules/git/gitcmd/command_race_test.go deleted file mode 100644 index aee2272808b..00000000000 --- a/modules/git/gitcmd/command_race_test.go +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright 2017 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -//go:build race - -package gitcmd - -import ( - "context" - "testing" - "time" -) - -func TestRunWithContextNoTimeout(t *testing.T) { - maxLoops := 10 - - // 'git --version' does not block so it must be finished before the timeout triggered. - cmd := NewCommand("--version") - for i := 0; i < maxLoops; i++ { - if err := cmd.Run(t.Context(), &RunOpts{}); err != nil { - t.Fatal(err) - } - } -} - -func TestRunWithContextTimeout(t *testing.T) { - maxLoops := 10 - - // 'git hash-object --stdin' blocks on stdin so we can have the timeout triggered. - cmd := NewCommand("hash-object", "--stdin") - for i := 0; i < maxLoops; i++ { - if err := cmd.Run(t.Context(), &RunOpts{Timeout: 1 * time.Millisecond}); err != nil { - if err != context.DeadlineExceeded { - t.Fatalf("Testing %d/%d: %v", i, maxLoops, err) - } - } - } -} diff --git a/modules/git/gitcmd/command_test.go b/modules/git/gitcmd/command_test.go index 544a97f64c1..6e4214d9953 100644 --- a/modules/git/gitcmd/command_test.go +++ b/modules/git/gitcmd/command_test.go @@ -4,57 +4,93 @@ package gitcmd import ( + "context" "fmt" "os" + "strings" "testing" + "time" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/tempdir" + "code.gitea.io/gitea/modules/testlogger" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func TestMain(m *testing.M) { +func testMain(m *testing.M) int { + // FIXME: GIT-PACKAGE-DEPENDENCY: the dependency is not right. + // "setting.Git.HomePath" is initialized in "git" package but really used in "gitcmd" package gitHomePath, cleanup, err := tempdir.OsTempDir("gitea-test").MkdirTempRandom("git-home") if err != nil { - _, _ = fmt.Fprintf(os.Stderr, "unable to create temp dir: %v", err) - os.Exit(1) + testlogger.Panicf("failed to create temp dir: %v", err) } defer cleanup() setting.Git.HomePath = gitHomePath + return m.Run() +} + +func TestMain(m *testing.M) { + os.Exit(testMain(m)) } func TestRunWithContextStd(t *testing.T) { - cmd := NewCommand("--version") - stdout, stderr, err := cmd.RunStdString(t.Context(), &RunOpts{}) - assert.NoError(t, err) - assert.Empty(t, stderr) - assert.Contains(t, stdout, "git version") - - cmd = NewCommand("--no-such-arg") - stdout, stderr, err = cmd.RunStdString(t.Context(), &RunOpts{}) - if assert.Error(t, err) { - assert.Equal(t, stderr, err.Stderr()) - assert.Contains(t, err.Stderr(), "unknown option:") - assert.Contains(t, err.Error(), "exit status 129 - unknown option:") - assert.Empty(t, stdout) + { + cmd := NewCommand("--version") + stdout, stderr, err := cmd.RunStdString(t.Context()) + assert.NoError(t, err) + assert.Empty(t, stderr) + assert.Contains(t, stdout, "git version") } - cmd = NewCommand() - cmd.AddDynamicArguments("-test") - assert.ErrorIs(t, cmd.Run(t.Context(), &RunOpts{}), ErrBrokenCommand) + { + cmd := NewCommand("ls-tree", "no-such") + stdout, stderr, err := cmd.RunStdString(t.Context()) + if assert.Error(t, err) { + assert.Equal(t, stderr, err.Stderr()) + stderrLower := strings.ToLower(stderr) // see: IsStdErrorNotValidObjectName + assert.Equal(t, "fatal: not a valid object name no-such\n", stderrLower) + // FIXME: GIT-CMD-STDERR: it is a bad design, the stderr should not be put in the error message + errLower := strings.ToLower(err.Error()) + assert.Equal(t, "exit status 128 - fatal: not a valid object name no-such", errLower) + assert.Empty(t, stdout) + } + } - cmd = NewCommand() - cmd.AddDynamicArguments("--test") - assert.ErrorIs(t, cmd.Run(t.Context(), &RunOpts{}), ErrBrokenCommand) + { + cmd := NewCommand("ls-tree", "no-such") + stdout, stderr, err := cmd.RunStdBytes(t.Context()) + if assert.Error(t, err) { + assert.Equal(t, string(stderr), err.Stderr()) + stderrLower := strings.ToLower(err.Stderr()) // see: IsStdErrorNotValidObjectName + assert.Equal(t, "fatal: not a valid object name no-such\n", stderrLower) + // FIXME: GIT-CMD-STDERR: it is a bad design, the stderr should not be put in the error message + errLower := strings.ToLower(err.Error()) + assert.Equal(t, "exit status 128 - fatal: not a valid object name no-such", errLower) + assert.Empty(t, stdout) + } + } - subCmd := "version" - cmd = NewCommand().AddDynamicArguments(subCmd) // for test purpose only, the sub-command should never be dynamic for production - stdout, stderr, err = cmd.RunStdString(t.Context(), &RunOpts{}) - assert.NoError(t, err) - assert.Empty(t, stderr) - assert.Contains(t, stdout, "git version") + { + cmd := NewCommand() + cmd.AddDynamicArguments("-test") + assert.ErrorIs(t, cmd.Run(t.Context()), ErrBrokenCommand) + + cmd = NewCommand() + cmd.AddDynamicArguments("--test") + assert.ErrorIs(t, cmd.Run(t.Context()), ErrBrokenCommand) + } + + { + subCmd := "version" + cmd := NewCommand().AddDynamicArguments(subCmd) // for test purpose only, the sub-command should never be dynamic for production + stdout, stderr, err := cmd.RunStdString(t.Context()) + assert.NoError(t, err) + assert.Empty(t, stderr) + assert.Contains(t, stdout, "git version") + } } func TestGitArgument(t *testing.T) { @@ -75,3 +111,29 @@ func TestCommandString(t *testing.T) { cmd = NewCommand("url: https://a:b@c/", "/root/dir-a/dir-b") assert.Equal(t, cmd.prog+` "url: https://sanitized-credential@c/" .../dir-a/dir-b`, cmd.LogString()) } + +func TestRunStdError(t *testing.T) { + e := &runStdError{stderr: "some error"} + var err RunStdError = e + + var asErr RunStdError + require.ErrorAs(t, err, &asErr) + require.Equal(t, "some error", asErr.Stderr()) + + require.ErrorAs(t, fmt.Errorf("wrapped %w", err), &asErr) +} + +func TestRunWithContextTimeout(t *testing.T) { + t.Run("NoTimeout", func(t *testing.T) { + // 'git --version' does not block so it must be finished before the timeout triggered. + err := NewCommand("--version").Run(t.Context()) + require.NoError(t, err) + }) + t.Run("WithTimeout", func(t *testing.T) { + cmd := NewCommand("hash-object", "--stdin") + _, _, pipeClose := cmd.MakeStdinStdoutPipe() + defer pipeClose() + err := cmd.WithTimeout(1 * time.Millisecond).Run(t.Context()) + require.ErrorIs(t, err, context.DeadlineExceeded) + }) +} diff --git a/modules/git/gitcmd/context.go b/modules/git/gitcmd/context.go new file mode 100644 index 00000000000..a32f92ff3aa --- /dev/null +++ b/modules/git/gitcmd/context.go @@ -0,0 +1,32 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitcmd + +import ( + "context" +) + +type Context interface { + context.Context + + // CancelPipeline is a helper function to cancel the command context (kill the command) with a specific error cause, + // it returns the same error for convenience to break the PipelineFunc easily + CancelPipeline(err error) error + + // In the future, this interface will be extended to support stdio pipe readers/writers +} + +type cmdContext struct { + context.Context + cmd *Command +} + +func (c *cmdContext) CancelPipeline(err error) error { + // pipelineError is used to distinguish between: + // * context canceled by pipeline caller with/without error (normal cancellation) + // * context canceled by parent context (still context.Canceled error) + // * other causes + c.cmd.cmdCancel(pipelineError{err}) + return err +} diff --git a/modules/git/gitcmd/error.go b/modules/git/gitcmd/error.go new file mode 100644 index 00000000000..b674068c400 --- /dev/null +++ b/modules/git/gitcmd/error.go @@ -0,0 +1,108 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitcmd + +import ( + "context" + "errors" + "fmt" + "os/exec" + "strings" +) + +type RunStdError interface { + error + Unwrap() error + Stderr() string +} + +type runStdError struct { + err error // usually the low-level error like `*exec.ExitError` + stderr string // git command's stderr output + errMsg string // the cached error message for Error() method +} + +func (r *runStdError) Error() string { + // FIXME: GIT-CMD-STDERR: it is a bad design, the stderr should not be put in the error message + // But a lot of code only checks `strings.Contains(err.Error(), "git error")` + if r.errMsg == "" { + r.errMsg = fmt.Sprintf("%s - %s", r.err.Error(), strings.TrimSpace(r.stderr)) + } + return r.errMsg +} + +func (r *runStdError) Unwrap() error { + return r.err +} + +func (r *runStdError) Stderr() string { + return r.stderr +} + +func ErrorAsStderr(err error) (string, bool) { + var runErr RunStdError + if errors.As(err, &runErr) { + return runErr.Stderr(), true + } + return "", false +} + +func StderrHasPrefix(err error, prefix string) bool { + stderr, ok := ErrorAsStderr(err) + if !ok { + return false + } + return strings.HasPrefix(stderr, prefix) +} + +func IsErrorExitCode(err error, code int) bool { + var exitError *exec.ExitError + if errors.As(err, &exitError) { + return exitError.ExitCode() == code + } + return false +} + +func IsErrorSignalKilled(err error) bool { + var exitError *exec.ExitError + return errors.As(err, &exitError) && exitError.String() == "signal: killed" +} + +func IsErrorCanceledOrKilled(err error) bool { + // When "cancel()" a git command's context, the returned error of "Run()" could be one of them: + // - context.Canceled + // - *exec.ExitError: "signal: killed" + // TODO: in the future, we need to use unified error type from gitcmd.Run to check whether it is manually canceled + return errors.Is(err, context.Canceled) || IsErrorSignalKilled(err) +} + +func IsStdErrorNotValidObjectName(err error) bool { + stderr, ok := ErrorAsStderr(err) + // Git is lowercasing the "fatal: Not a valid object name" error message + // ref: https://lore.kernel.org/git/pull.2052.git.1771836302101.gitgitgadget@gmail.com + return ok && strings.Contains(strings.ToLower(stderr), "fatal: not a valid object name") +} + +type pipelineError struct { + error +} + +func (e pipelineError) Unwrap() error { + return e.error +} + +func wrapPipelineError(err error) error { + if err == nil { + return nil + } + return pipelineError{err} +} + +func UnwrapPipelineError(err error) (error, bool) { //nolint:revive // this is for error unwrapping + var pe pipelineError + if errors.As(err, &pe) { + return pe.error, true + } + return nil, false +} diff --git a/modules/git/gitcmd/pipe.go b/modules/git/gitcmd/pipe.go new file mode 100644 index 00000000000..d0ce3e2dc63 --- /dev/null +++ b/modules/git/gitcmd/pipe.go @@ -0,0 +1,87 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitcmd + +import ( + "io" + "os" +) + +type PipeBufferReader interface { + // Read should be used in the same goroutine as command's Wait + // When Reader in one goroutine, command's Wait in another goroutine, then the command exits, the pipe will be closed: + // * If the Reader goroutine reads faster, it will read all remaining data and then get io.EOF + // * But this io.EOF doesn't mean the Reader has gotten complete data, the data might still be corrupted + // * If the Reader goroutine reads slower, it will get os.ErrClosed because the os.Pipe is closed ahead when the command exits + // + // When using 2 goroutines, no clear solution to distinguish these two cases or make Reader knows whether the data is complete + // It should avoid using Reader in a different goroutine than the command if the Read error needs to be handled. + Read(p []byte) (n int, err error) + Bytes() []byte +} + +type PipeBufferWriter interface { + Write(p []byte) (n int, err error) + Bytes() []byte +} + +type PipeReader interface { + io.ReadCloser + internalOnly() +} + +type pipeReader struct { + f *os.File +} + +func (r *pipeReader) internalOnly() {} + +func (r *pipeReader) Read(p []byte) (n int, err error) { + return r.f.Read(p) +} + +func (r *pipeReader) Close() error { + return r.f.Close() +} + +type PipeWriter interface { + io.WriteCloser + internalOnly() +} + +type pipeWriter struct { + f *os.File +} + +func (w *pipeWriter) internalOnly() {} + +func (w *pipeWriter) Close() error { + return w.f.Close() +} + +func (w *pipeWriter) Write(p []byte) (n int, err error) { + return w.f.Write(p) +} + +func (w *pipeWriter) DrainBeforeClose() error { + return nil +} + +type pipeNull struct { + err error +} + +func (p *pipeNull) internalOnly() {} + +func (p *pipeNull) Read([]byte) (n int, err error) { + return 0, p.err +} + +func (p *pipeNull) Write([]byte) (n int, err error) { + return 0, p.err +} + +func (p *pipeNull) Close() error { + return nil +} diff --git a/modules/git/gitcmd/utils.go b/modules/git/gitcmd/utils.go deleted file mode 100644 index ee24eb6a9a1..00000000000 --- a/modules/git/gitcmd/utils.go +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright 2025 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package gitcmd - -import "fmt" - -// ConcatenateError concatenats an error with stderr string -func ConcatenateError(err error, stderr string) error { - if len(stderr) == 0 { - return err - } - return fmt.Errorf("%w - %s", err, stderr) -} diff --git a/modules/git/gpg.go b/modules/git/gpg.go new file mode 100644 index 00000000000..dbc5569309c --- /dev/null +++ b/modules/git/gpg.go @@ -0,0 +1,102 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "context" + "fmt" + "os" + "strings" + "sync" + + "code.gitea.io/gitea/modules/git/gitcmd" + "code.gitea.io/gitea/modules/process" +) + +// GPGSettings represents the default GPG settings for this repository +type GPGSettings struct { + Sign bool + KeyID string + Email string + Name string + PublicKeyContent string + Format string +} + +// LoadPublicKeyContent will load the key from gpg +func (gpgSettings *GPGSettings) LoadPublicKeyContent() error { + if gpgSettings.PublicKeyContent != "" { + return nil + } + + if gpgSettings.Format == SigningKeyFormatSSH { + content, err := os.ReadFile(gpgSettings.KeyID) + if err != nil { + return fmt.Errorf("unable to read SSH public key file: %s, %w", gpgSettings.KeyID, err) + } + gpgSettings.PublicKeyContent = string(content) + return nil + } + content, stderr, err := process.GetManager().Exec( + "gpg -a --export", + "gpg", "-a", "--export", gpgSettings.KeyID) + if err != nil { + return fmt.Errorf("unable to get default signing key: %s, %s, %w", gpgSettings.KeyID, stderr, err) + } + gpgSettings.PublicKeyContent = content + return nil +} + +var ( + loadPublicGPGKeyMutex sync.RWMutex + globalGPGSettings *GPGSettings +) + +// GetDefaultPublicGPGKey will return and cache the default public GPG settings +func GetDefaultPublicGPGKey(ctx context.Context, forceUpdate bool) (*GPGSettings, error) { + if !forceUpdate { + loadPublicGPGKeyMutex.RLock() + if globalGPGSettings != nil { + defer loadPublicGPGKeyMutex.RUnlock() + return globalGPGSettings, nil + } + loadPublicGPGKeyMutex.RUnlock() + } + + loadPublicGPGKeyMutex.Lock() + defer loadPublicGPGKeyMutex.Unlock() + + if globalGPGSettings != nil && !forceUpdate { + return globalGPGSettings, nil + } + + globalGPGSettings = &GPGSettings{ + Sign: true, + } + + value, _, _ := gitcmd.NewCommand("config", "--global", "--get", "commit.gpgsign").RunStdString(ctx) + sign, valid := ParseBool(strings.TrimSpace(value)) + if !sign || !valid { + globalGPGSettings.Sign = false + return globalGPGSettings, nil + } + + signingKey, _, _ := gitcmd.NewCommand("config", "--global", "--get", "user.signingkey").RunStdString(ctx) + globalGPGSettings.KeyID = strings.TrimSpace(signingKey) + + format, _, _ := gitcmd.NewCommand("config", "--global", "--default", SigningKeyFormatOpenPGP, "--get", "gpg.format").RunStdString(ctx) + globalGPGSettings.Format = strings.TrimSpace(format) + + defaultEmail, _, _ := gitcmd.NewCommand("config", "--global", "--get", "user.email").RunStdString(ctx) + globalGPGSettings.Email = strings.TrimSpace(defaultEmail) + + defaultName, _, _ := gitcmd.NewCommand("config", "--global", "--get", "user.name").RunStdString(ctx) + globalGPGSettings.Name = strings.TrimSpace(defaultName) + + if err := globalGPGSettings.LoadPublicKeyContent(); err != nil { + return nil, err + } + return globalGPGSettings, nil +} diff --git a/modules/git/grep.go b/modules/git/grep.go index f5f6f120416..47f66b88b24 100644 --- a/modules/git/grep.go +++ b/modules/git/grep.go @@ -5,14 +5,13 @@ package git import ( "bufio" - "bytes" "context" "errors" "fmt" - "os" "slices" "strconv" "strings" + "time" "code.gitea.io/gitea/modules/git/gitcmd" "code.gitea.io/gitea/modules/util" @@ -41,16 +40,11 @@ type GrepOptions struct { PathspecList []string } -func GrepSearch(ctx context.Context, repo *Repository, search string, opts GrepOptions) ([]*GrepResult, error) { - stdoutReader, stdoutWriter, err := os.Pipe() - if err != nil { - return nil, fmt.Errorf("unable to create os pipe to grep: %w", err) - } - defer func() { - _ = stdoutReader.Close() - _ = stdoutWriter.Close() - }() +// grepSearchTimeout is the timeout for git grep search, it should be long enough to get results +// but not too long to cause performance issues +const grepSearchTimeout = 30 * time.Second +func GrepSearch(ctx context.Context, repo *Repository, search string, opts GrepOptions) ([]*GrepResult, error) { /* The output is like this ( "^@" means \x00): @@ -83,15 +77,12 @@ func GrepSearch(ctx context.Context, repo *Repository, search string, opts GrepO cmd.AddDynamicArguments(util.IfZero(opts.RefName, "HEAD")) cmd.AddDashesAndList(opts.PathspecList...) opts.MaxResultLimit = util.IfZero(opts.MaxResultLimit, 50) - stderr := bytes.Buffer{} - err = cmd.Run(ctx, &gitcmd.RunOpts{ - Dir: repo.Path, - Stdout: stdoutWriter, - Stderr: &stderr, - PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error { - _ = stdoutWriter.Close() - defer stdoutReader.Close() + stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe() + defer stdoutReaderClose() + err := cmd.WithDir(repo.Path). + WithTimeout(grepSearchTimeout). + WithPipelineFunc(func(ctx gitcmd.Context) error { isInBlock := false rd := bufio.NewReaderSize(stdoutReader, util.IfZero(opts.MaxLineLength, 16*1024)) var res *GrepResult @@ -117,8 +108,7 @@ func GrepSearch(ctx context.Context, repo *Repository, search string, opts GrepO } if line == "" { if len(results) >= opts.MaxResultLimit { - cancel() - break + return ctx.CancelPipeline(nil) } isInBlock = false continue @@ -133,18 +123,18 @@ func GrepSearch(ctx context.Context, repo *Repository, search string, opts GrepO } } return nil - }, - }) + }). + RunWithStderr(ctx) // git grep exits by cancel (killed), usually it is caused by the limit of results - if gitcmd.IsErrorExitCode(err, -1) && stderr.Len() == 0 { + if gitcmd.IsErrorExitCode(err, -1) && err.Stderr() == "" { return results, nil } // git grep exits with 1 if no results are found - if gitcmd.IsErrorExitCode(err, 1) && stderr.Len() == 0 { + if gitcmd.IsErrorExitCode(err, 1) && err.Stderr() == "" { return nil, nil } if err != nil && !errors.Is(err, context.Canceled) { - return nil, fmt.Errorf("unable to run git grep: %w, stderr: %s", err, stderr.String()) + return nil, fmt.Errorf("unable to run git grep: %w", err) } return results, nil } diff --git a/modules/git/hook.go b/modules/git/hook.go index 548a59971db..0e19387d976 100644 --- a/modules/git/hook.go +++ b/modules/git/hook.go @@ -45,32 +45,18 @@ func GetHook(repoPath, name string) (*Hook, error) { } h := &Hook{ name: name, - path: filepath.Join(repoPath, "hooks", name+".d", name), + path: filepath.Join(repoPath, filepath.Join("hooks", name+".d", name)), } - isFile, err := util.IsFile(h.path) - if err != nil { - return nil, err - } - if isFile { - data, err := os.ReadFile(h.path) - if err != nil { - return nil, err - } + if data, err := os.ReadFile(h.path); err == nil { h.IsActive = true h.Content = string(data) return h, nil + } else if !os.IsNotExist(err) { + return nil, err } samplePath := filepath.Join(repoPath, "hooks", name+".sample") - isFile, err = util.IsFile(samplePath) - if err != nil { - return nil, err - } - if isFile { - data, err := os.ReadFile(samplePath) - if err != nil { - return nil, err - } + if data, err := os.ReadFile(samplePath); err == nil { h.Sample = string(data) } return h, nil diff --git a/modules/git/key.go b/modules/git/key.go index 8c14742f34a..9d51704595d 100644 --- a/modules/git/key.go +++ b/modules/git/key.go @@ -3,7 +3,13 @@ package git -import "code.gitea.io/gitea/modules/setting" +import ( + "context" + "strings" + + "code.gitea.io/gitea/modules/git/gitcmd" + "code.gitea.io/gitea/modules/setting" +) // Based on https://git-scm.com/docs/git-config#Documentation/git-config.txt-gpgformat const ( @@ -24,3 +30,48 @@ func (s *SigningKey) String() string { setting.PanicInDevOrTesting("don't call SigningKey.String() - it exposes the KeyID which might be a local file path") return "SigningKey:" + s.Format } + +// GetSigningKey returns the KeyID and git Signature for the repo +func GetSigningKey(ctx context.Context) (*SigningKey, *Signature) { + if setting.Repository.Signing.SigningKey == "none" { + return nil, nil + } + + if setting.Repository.Signing.SigningKey == "default" || setting.Repository.Signing.SigningKey == "" { + // Can ignore the error here as it means that commit.gpgsign is not set + value, _, _ := gitcmd.NewCommand("config", "--global", "--get", "commit.gpgsign").RunStdString(ctx) + sign, valid := ParseBool(strings.TrimSpace(value)) + if !sign || !valid { + return nil, nil + } + + format, _, _ := gitcmd.NewCommand("config", "--global", "--default", SigningKeyFormatOpenPGP, "--get", "gpg.format").RunStdString(ctx) + signingKey, _, _ := gitcmd.NewCommand("config", "--global", "--get", "user.signingkey").RunStdString(ctx) + signingName, _, _ := gitcmd.NewCommand("config", "--global", "--get", "user.name").RunStdString(ctx) + signingEmail, _, _ := gitcmd.NewCommand("config", "--global", "--get", "user.email").RunStdString(ctx) + + if strings.TrimSpace(signingKey) == "" { + return nil, nil + } + + return &SigningKey{ + KeyID: strings.TrimSpace(signingKey), + Format: strings.TrimSpace(format), + }, &Signature{ + Name: strings.TrimSpace(signingName), + Email: strings.TrimSpace(signingEmail), + } + } + + if setting.Repository.Signing.SigningKey == "" { + return nil, nil + } + + return &SigningKey{ + KeyID: setting.Repository.Signing.SigningKey, + Format: setting.Repository.Signing.SigningFormat, + }, &Signature{ + Name: setting.Repository.Signing.SigningName, + Email: setting.Repository.Signing.SigningEmail, + } +} diff --git a/modules/git/languagestats/language_stats_gogit.go b/modules/git/languagestats/language_stats_gogit.go index 418c05b1578..ec03ca31597 100644 --- a/modules/git/languagestats/language_stats_gogit.go +++ b/modules/git/languagestats/language_stats_gogit.go @@ -108,7 +108,7 @@ func GetLanguageStats(repo *git_module.Repository, commitID string) (map[string] if (!isVendored.Has() && analyze.IsVendor(f.Name)) || enry.IsDotFile(f.Name) || (!isDocumentation.Has() && enry.IsDocumentation(f.Name)) || - enry.IsConfiguration(f.Name) { + (!isDetectable.Has() && enry.IsConfiguration(f.Name)) { return nil } diff --git a/modules/git/languagestats/language_stats_nogogit.go b/modules/git/languagestats/language_stats_nogogit.go index 94cf9fff8c1..442313d4959 100644 --- a/modules/git/languagestats/language_stats_nogogit.go +++ b/modules/git/languagestats/language_stats_nogogit.go @@ -22,33 +22,28 @@ import ( 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) + batch, cancel, err := repo.CatFileBatch(repo.Ctx) if err != nil { return nil, err } defer cancel() - writeID := func(id string) error { - _, err := batchStdinWriter.Write([]byte(id + "\n")) - return err - } - - if err := writeID(commitID); err != nil { + commitInfo, batchReader, err := batch.QueryContent(commitID) + if err != nil { return nil, err } - shaBytes, typ, size, err := git.ReadBatchLine(batchReader) - if typ != "commit" { + if commitInfo.Type != "commit" { log.Debug("Unable to get commit for: %s. Err: %v", commitID, err) return nil, git.ErrNotExist{ID: commitID} } - sha, err := git.NewIDFromString(string(shaBytes)) + sha, err := git.NewIDFromString(commitInfo.ID) if err != nil { log.Debug("Unable to get commit for: %s. Err: %v", commitID, err) return nil, git.ErrNotExist{ID: commitID} } - commit, err := git.CommitFromReader(repo, sha, io.LimitReader(batchReader, size)) + commit, err := git.CommitFromReader(repo, sha, io.LimitReader(batchReader, commitInfo.Size)) if err != nil { log.Debug("Unable to get commit for: %s. Err: %v", commitID, err) return nil, err @@ -137,27 +132,23 @@ func GetLanguageStats(repo *git.Repository, commitID string) (map[string]int64, if (!isVendored.Has() && analyze.IsVendor(f.Name())) || enry.IsDotFile(f.Name()) || (!isDocumentation.Has() && enry.IsDocumentation(f.Name())) || - enry.IsConfiguration(f.Name()) { + (!isDetectable.Has() && enry.IsConfiguration(f.Name())) { continue } // If content can not be read or file is too big just do detection by filename if f.Size() <= bigFileSize { - if err := writeID(f.ID.String()); err != nil { - return nil, err - } - _, _, size, err := git.ReadBatchLine(batchReader) + info, _, err := batch.QueryContent(f.ID.String()) if err != nil { - log.Debug("Error reading blob: %s Err: %v", f.ID.String(), err) return nil, err } - sizeToRead := size + sizeToRead := info.Size discard := int64(1) - if size > fileSizeLimit { + if info.Size > fileSizeLimit { sizeToRead = fileSizeLimit - discard = size - fileSizeLimit + 1 + discard = info.Size - fileSizeLimit + 1 } _, err = contentBuf.ReadFrom(io.LimitReader(batchReader, sizeToRead)) diff --git a/modules/git/languagestats/main_test.go b/modules/git/languagestats/main_test.go index b8f9ded005a..bf860f2a188 100644 --- a/modules/git/languagestats/main_test.go +++ b/modules/git/languagestats/main_test.go @@ -4,37 +4,11 @@ package languagestats import ( - "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(); 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) - } + git.RunGitTests(m) } diff --git a/modules/git/last_commit_cache.go b/modules/git/last_commit_cache.go index cff2556083d..a013773b470 100644 --- a/modules/git/last_commit_cache.go +++ b/modules/git/last_commit_cache.go @@ -55,12 +55,12 @@ func (c *LastCommitCache) Put(ref, entryPath, commitID string) error { // Get gets the last commit information by commit id and entry path func (c *LastCommitCache) Get(ref, entryPath string) (*Commit, error) { if c == nil || c.cache == nil { - return nil, nil + return nil, nil //nolint:nilnil // return nil when cache is not available } commitID, ok := c.cache.Get(getCacheKey(c.repoPath, ref, entryPath)) if !ok || commitID == "" { - return nil, nil + return nil, nil //nolint:nilnil // return nil when cache miss } log.Debug("LastCommitCache hit level 1: [%s:%s:%s]", ref, entryPath, commitID) diff --git a/modules/git/log_name_status.go b/modules/git/log_name_status.go index 7a5192f58b7..6e6d9985ae0 100644 --- a/modules/git/log_name_status.go +++ b/modules/git/log_name_status.go @@ -15,25 +15,12 @@ import ( "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/git/gitcmd" - - "github.com/djherbis/buffer" - "github.com/djherbis/nio/v3" + "code.gitea.io/gitea/modules/log" ) // LogNameStatusRepo opens git log --raw in the provided repo and returns a stdin pipe, a stdout reader and cancel function func LogNameStatusRepo(ctx context.Context, repository, head, treepath string, paths ...string) (*bufio.Reader, func()) { - // We often want to feed the commits in order into cat-file --batch, followed by their trees and sub trees as necessary. - // so let's create a batch stdin and stdout - stdoutReader, stdoutWriter := nio.Pipe(buffer.New(32 * 1024)) - // Lets also create a context so that we can absolutely ensure that the command should die when we're done - ctx, ctxCancel := context.WithCancel(ctx) - - cancel := func() { - ctxCancel() - _ = stdoutReader.Close() - _ = stdoutWriter.Close() - } cmd := gitcmd.NewCommand() cmd.AddArguments("log", "--name-status", "-c", "--format=commit%x00%H %P%x00", "--parents", "--no-renames", "-t", "-z").AddDynamicArguments(head) @@ -63,25 +50,21 @@ func LogNameStatusRepo(ctx context.Context, repository, head, treepath string, p } cmd.AddDashesAndList(files...) + stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe() + ctx, ctxCancel := context.WithCancel(ctx) go func() { - stderr := strings.Builder{} - err := cmd.Run(ctx, &gitcmd.RunOpts{ - Dir: repository, - Stdout: stdoutWriter, - Stderr: &stderr, - }) - if err != nil { - _ = stdoutWriter.CloseWithError(gitcmd.ConcatenateError(err, (&stderr).String())) - return + err := cmd.WithDir(repository).RunWithStderr(ctx) + if err != nil && !errors.Is(err, context.Canceled) { + log.Error("Unable to run git command %v: %v", cmd.LogString(), err) } - - _ = stdoutWriter.Close() }() - // For simplicities sake we'll us a buffered reader to read from the cat-file --batch bufReader := bufio.NewReaderSize(stdoutReader, 32*1024) - return bufReader, cancel + return bufReader, func() { + ctxCancel() + stdoutReaderClose() + } } // LogNameStatusRepoParser parses a git log raw output from LogRawRepo @@ -123,7 +106,7 @@ func (g *LogNameStatusRepoParser) Next(treepath string, paths2ids map[string]int case bufio.ErrBufferFull: g.buffull = true case io.EOF: - return nil, nil + return nil, nil //nolint:nilnil // return nil to signal EOF default: return nil, err } @@ -138,7 +121,7 @@ func (g *LogNameStatusRepoParser) Next(treepath string, paths2ids map[string]int case bufio.ErrBufferFull: g.buffull = true case io.EOF: - return nil, nil + return nil, nil //nolint:nilnil // return nil to signal EOF default: return nil, err } diff --git a/modules/git/notes_gogit.go b/modules/git/notes_gogit.go index f802443b00e..340f4d5ccc4 100644 --- a/modules/git/notes_gogit.go +++ b/modules/git/notes_gogit.go @@ -7,6 +7,7 @@ package git import ( "context" + "fmt" "io" "code.gitea.io/gitea/modules/log" @@ -30,7 +31,11 @@ func GetNote(ctx context.Context, repo *Repository, commitID string, note *Note) remainingCommitID := commitID path := "" - currentTree := notes.Tree.gogitTree + currentTree, err := notes.Tree.gogitTreeObject() + if err != nil { + return fmt.Errorf("unable to get tree object for notes commit %q: %w", notes.ID.String(), err) + } + log.Trace("Found tree with ID %q while searching for git note corresponding to the commit %q", currentTree.Entries[0].Name, commitID) var file *object.File for len(remainingCommitID) > 2 { diff --git a/modules/git/notes_test.go b/modules/git/notes_test.go index 7db2dbc0b9e..5abb68b1023 100644 --- a/modules/git/notes_test.go +++ b/modules/git/notes_test.go @@ -47,5 +47,5 @@ func TestGetNonExistentNotes(t *testing.T) { note := Note{} err = GetNote(t.Context(), bareRepo1, "non_existent_sha", ¬e) assert.Error(t, err) - assert.IsType(t, ErrNotExist{}, err) + assert.ErrorAs(t, err, &ErrNotExist{}) } diff --git a/modules/git/parse.go b/modules/git/parse.go index a7f5c58e896..94020e690dd 100644 --- a/modules/git/parse.go +++ b/modules/git/parse.go @@ -27,15 +27,15 @@ func parseLsTreeLine(line []byte) (*LsTreeEntry, error) { // \t var err error - posTab := bytes.IndexByte(line, '\t') - if posTab == -1 { + before, after, ok := bytes.Cut(line, []byte{'\t'}) + if !ok { return nil, fmt.Errorf("invalid ls-tree output (no tab): %q", line) } entry := new(LsTreeEntry) - entryAttrs := line[:posTab] - entryName := line[posTab+1:] + entryAttrs := before + entryName := after entryMode, entryAttrs, _ := bytes.Cut(entryAttrs, sepSpace) _ /* entryType */, entryAttrs, _ = bytes.Cut(entryAttrs, sepSpace) // the type is not used, the mode is enough to determine the type @@ -46,8 +46,8 @@ func parseLsTreeLine(line []byte) (*LsTreeEntry, error) { entry.Size = optional.Some(size) } - entry.EntryMode, err = ParseEntryMode(string(entryMode)) - if err != nil || entry.EntryMode == EntryModeNoEntry { + entry.EntryMode = ParseEntryMode(string(entryMode)) + if entry.EntryMode == EntryModeNoEntry { return nil, fmt.Errorf("invalid ls-tree output (invalid mode): %q, err: %w", line, err) } diff --git a/modules/git/parse_gogit.go b/modules/git/parse_gogit.go deleted file mode 100644 index 74d258de8eb..00000000000 --- a/modules/git/parse_gogit.go +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright 2018 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -//go:build gogit - -package git - -import ( - "bytes" - "fmt" - "strconv" - "strings" - - "github.com/go-git/go-git/v5/plumbing" - "github.com/go-git/go-git/v5/plumbing/filemode" - "github.com/go-git/go-git/v5/plumbing/hash" - "github.com/go-git/go-git/v5/plumbing/object" -) - -// ParseTreeEntries parses the output of a `git ls-tree -l` command. -func ParseTreeEntries(data []byte) ([]*TreeEntry, error) { - return parseTreeEntries(data, nil) -} - -func parseTreeEntries(data []byte, ptree *Tree) ([]*TreeEntry, error) { - entries := make([]*TreeEntry, 0, 10) - for pos := 0; pos < len(data); { - // expect line to be of the form " \t" - entry := new(TreeEntry) - entry.gogitTreeEntry = &object.TreeEntry{} - entry.ptree = ptree - if pos+6 > len(data) { - return nil, fmt.Errorf("Invalid ls-tree output: %s", string(data)) - } - switch string(data[pos : pos+6]) { - case "100644": - entry.gogitTreeEntry.Mode = filemode.Regular - pos += 12 // skip over "100644 blob " - case "100755": - entry.gogitTreeEntry.Mode = filemode.Executable - pos += 12 // skip over "100755 blob " - case "120000": - entry.gogitTreeEntry.Mode = filemode.Symlink - pos += 12 // skip over "120000 blob " - case "160000": - entry.gogitTreeEntry.Mode = filemode.Submodule - pos += 14 // skip over "160000 object " - case "040000": - entry.gogitTreeEntry.Mode = filemode.Dir - pos += 12 // skip over "040000 tree " - default: - return nil, fmt.Errorf("unknown type: %v", string(data[pos:pos+6])) - } - - // in hex format, not byte format .... - if pos+hash.Size*2 > len(data) { - return nil, fmt.Errorf("Invalid ls-tree output: %s", string(data)) - } - var err error - entry.ID, err = NewIDFromString(string(data[pos : pos+hash.Size*2])) - if err != nil { - return nil, fmt.Errorf("invalid ls-tree output: %w", err) - } - entry.gogitTreeEntry.Hash = plumbing.Hash(entry.ID.RawValue()) - pos += 41 // skip over sha and trailing space - - end := pos + bytes.IndexByte(data[pos:], '\t') - if end < pos { - return nil, fmt.Errorf("Invalid ls-tree -l output: %s", string(data)) - } - entry.size, _ = strconv.ParseInt(strings.TrimSpace(string(data[pos:end])), 10, 64) - entry.sized = true - - pos = end + 1 - - end = pos + bytes.IndexByte(data[pos:], '\n') - if end < pos { - return nil, fmt.Errorf("Invalid ls-tree output: %s", string(data)) - } - - // In case entry name is surrounded by double quotes(it happens only in git-shell). - if data[pos] == '"' { - var err error - entry.gogitTreeEntry.Name, err = strconv.Unquote(string(data[pos:end])) - if err != nil { - return nil, fmt.Errorf("Invalid ls-tree output: %w", err) - } - } else { - entry.gogitTreeEntry.Name = string(data[pos:end]) - } - - pos = end + 1 - entries = append(entries, entry) - } - return entries, nil -} diff --git a/modules/git/parse_gogit_test.go b/modules/git/parse_gogit_test.go deleted file mode 100644 index 3e171d7e560..00000000000 --- a/modules/git/parse_gogit_test.go +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright 2018 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -//go:build gogit - -package git - -import ( - "fmt" - "testing" - - "github.com/go-git/go-git/v5/plumbing" - "github.com/go-git/go-git/v5/plumbing/filemode" - "github.com/go-git/go-git/v5/plumbing/object" - "github.com/stretchr/testify/assert" -) - -func TestParseTreeEntries(t *testing.T) { - testCases := []struct { - Input string - Expected []*TreeEntry - }{ - { - Input: "", - Expected: []*TreeEntry{}, - }, - { - Input: "100644 blob 61ab7345a1a3bbc590068ccae37b8515cfc5843c 1022\texample/file2.txt\n", - Expected: []*TreeEntry{ - { - ID: MustIDFromString("61ab7345a1a3bbc590068ccae37b8515cfc5843c"), - gogitTreeEntry: &object.TreeEntry{ - Hash: plumbing.Hash(MustIDFromString("61ab7345a1a3bbc590068ccae37b8515cfc5843c").RawValue()), - Name: "example/file2.txt", - Mode: filemode.Regular, - }, - size: 1022, - sized: true, - }, - }, - }, - { - Input: "120000 blob 61ab7345a1a3bbc590068ccae37b8515cfc5843c 234131\t\"example/\\n.txt\"\n" + - "040000 tree 1d01fb729fb0db5881daaa6030f9f2d3cd3d5ae8 -\texample\n", - Expected: []*TreeEntry{ - { - ID: MustIDFromString("61ab7345a1a3bbc590068ccae37b8515cfc5843c"), - gogitTreeEntry: &object.TreeEntry{ - Hash: plumbing.Hash(MustIDFromString("61ab7345a1a3bbc590068ccae37b8515cfc5843c").RawValue()), - Name: "example/\n.txt", - Mode: filemode.Symlink, - }, - size: 234131, - sized: true, - }, - { - ID: MustIDFromString("1d01fb729fb0db5881daaa6030f9f2d3cd3d5ae8"), - sized: true, - gogitTreeEntry: &object.TreeEntry{ - Hash: plumbing.Hash(MustIDFromString("1d01fb729fb0db5881daaa6030f9f2d3cd3d5ae8").RawValue()), - Name: "example", - Mode: filemode.Dir, - }, - }, - }, - }, - } - - for _, testCase := range testCases { - entries, err := ParseTreeEntries([]byte(testCase.Input)) - assert.NoError(t, err) - if len(entries) > 1 { - fmt.Println(testCase.Expected[0].ID) - fmt.Println(entries[0].ID) - } - assert.EqualValues(t, testCase.Expected, entries) - } -} diff --git a/modules/git/parse_nogogit.go b/modules/git/parse_treeentry.go similarity index 96% rename from modules/git/parse_nogogit.go rename to modules/git/parse_treeentry.go index 78a01628898..d46cd3344dc 100644 --- a/modules/git/parse_nogogit.go +++ b/modules/git/parse_treeentry.go @@ -1,12 +1,9 @@ // Copyright 2018 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -//go:build !gogit - package git import ( - "bufio" "bytes" "fmt" "io" @@ -49,7 +46,7 @@ func parseTreeEntries(data []byte, ptree *Tree) ([]*TreeEntry, error) { return entries, nil } -func catBatchParseTreeEntries(objectFormat ObjectFormat, ptree *Tree, rd *bufio.Reader, sz int64) ([]*TreeEntry, error) { +func catBatchParseTreeEntries(objectFormat ObjectFormat, ptree *Tree, rd BufferedReader, sz int64) ([]*TreeEntry, error) { fnameBuf := make([]byte, 4096) modeBuf := make([]byte, 40) shaBuf := make([]byte, objectFormat.FullLength()) diff --git a/modules/git/parse_nogogit_test.go b/modules/git/parse_treeentry_test.go similarity index 99% rename from modules/git/parse_nogogit_test.go rename to modules/git/parse_treeentry_test.go index 6594c842696..4223cbb3d76 100644 --- a/modules/git/parse_nogogit_test.go +++ b/modules/git/parse_treeentry_test.go @@ -1,8 +1,6 @@ // Copyright 2021 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -//go:build !gogit - package git import ( diff --git a/modules/git/pipeline/catfile.go b/modules/git/pipeline/catfile.go index ced8532e6d9..3d005e28f15 100644 --- a/modules/git/pipeline/catfile.go +++ b/modules/git/pipeline/catfile.go @@ -5,83 +5,34 @@ package pipeline import ( "bufio" - "bytes" "context" - "fmt" "io" "strconv" "strings" - "sync" "code.gitea.io/gitea/modules/git/gitcmd" - "code.gitea.io/gitea/modules/log" ) // CatFileBatchCheck runs cat-file with --batch-check -func CatFileBatchCheck(ctx context.Context, shasToCheckReader *io.PipeReader, catFileCheckWriter *io.PipeWriter, wg *sync.WaitGroup, tmpBasePath string) { - defer wg.Done() - defer shasToCheckReader.Close() - defer catFileCheckWriter.Close() - - stderr := new(bytes.Buffer) - var errbuf strings.Builder - cmd := gitcmd.NewCommand("cat-file", "--batch-check") - if err := cmd.Run(ctx, &gitcmd.RunOpts{ - Dir: tmpBasePath, - Stdin: shasToCheckReader, - Stdout: catFileCheckWriter, - Stderr: stderr, - }); err != nil { - _ = catFileCheckWriter.CloseWithError(fmt.Errorf("git cat-file --batch-check [%s]: %w - %s", tmpBasePath, err, errbuf.String())) - } +func CatFileBatchCheck(ctx context.Context, cmd *gitcmd.Command, tmpBasePath string) error { + cmd.AddArguments("cat-file", "--batch-check") + return cmd.WithDir(tmpBasePath).RunWithStderr(ctx) } // CatFileBatchCheckAllObjects runs cat-file with --batch-check --batch-all -func CatFileBatchCheckAllObjects(ctx context.Context, catFileCheckWriter *io.PipeWriter, wg *sync.WaitGroup, tmpBasePath string, errChan chan<- error) { - defer wg.Done() - defer catFileCheckWriter.Close() - - stderr := new(bytes.Buffer) - var errbuf strings.Builder - cmd := gitcmd.NewCommand("cat-file", "--batch-check", "--batch-all-objects") - if err := cmd.Run(ctx, &gitcmd.RunOpts{ - Dir: tmpBasePath, - Stdout: catFileCheckWriter, - Stderr: stderr, - }); err != nil { - log.Error("git cat-file --batch-check --batch-all-object [%s]: %v - %s", tmpBasePath, err, errbuf.String()) - err = fmt.Errorf("git cat-file --batch-check --batch-all-object [%s]: %w - %s", tmpBasePath, err, errbuf.String()) - _ = catFileCheckWriter.CloseWithError(err) - errChan <- err - } +func CatFileBatchCheckAllObjects(ctx context.Context, cmd *gitcmd.Command, tmpBasePath string) error { + return cmd.AddArguments("cat-file", "--batch-check", "--batch-all-objects").WithDir(tmpBasePath).RunWithStderr(ctx) } // CatFileBatch runs cat-file --batch -func CatFileBatch(ctx context.Context, shasToBatchReader *io.PipeReader, catFileBatchWriter *io.PipeWriter, wg *sync.WaitGroup, tmpBasePath string) { - defer wg.Done() - defer shasToBatchReader.Close() - defer catFileBatchWriter.Close() - - stderr := new(bytes.Buffer) - var errbuf strings.Builder - if err := gitcmd.NewCommand("cat-file", "--batch").Run(ctx, &gitcmd.RunOpts{ - Dir: tmpBasePath, - Stdout: catFileBatchWriter, - Stdin: shasToBatchReader, - Stderr: stderr, - }); err != nil { - _ = shasToBatchReader.CloseWithError(fmt.Errorf("git rev-list [%s]: %w - %s", tmpBasePath, err, errbuf.String())) - } +func CatFileBatch(ctx context.Context, cmd *gitcmd.Command, tmpBasePath string) error { + return cmd.AddArguments("cat-file", "--batch").WithDir(tmpBasePath).RunWithStderr(ctx) } // BlobsLessThan1024FromCatFileBatchCheck reads a pipeline from cat-file --batch-check and returns the blobs <1024 in size -func BlobsLessThan1024FromCatFileBatchCheck(catFileCheckReader *io.PipeReader, shasToBatchWriter *io.PipeWriter, wg *sync.WaitGroup) { - defer wg.Done() - defer catFileCheckReader.Close() - scanner := bufio.NewScanner(catFileCheckReader) - defer func() { - _ = shasToBatchWriter.CloseWithError(scanner.Err()) - }() +func BlobsLessThan1024FromCatFileBatchCheck(in io.ReadCloser, out io.WriteCloser) error { + defer out.Close() + scanner := bufio.NewScanner(in) for scanner.Scan() { line := scanner.Text() if len(line) == 0 { @@ -97,12 +48,12 @@ func BlobsLessThan1024FromCatFileBatchCheck(catFileCheckReader *io.PipeReader, s } toWrite := []byte(fields[0] + "\n") for len(toWrite) > 0 { - n, err := shasToBatchWriter.Write(toWrite) + n, err := out.Write(toWrite) if err != nil { - _ = catFileCheckReader.CloseWithError(err) - break + return err } toWrite = toWrite[n:] } } + return scanner.Err() } diff --git a/modules/git/pipeline/lfs_common.go b/modules/git/pipeline/lfs_common.go index 188e7d4d65d..914aefbeaf5 100644 --- a/modules/git/pipeline/lfs_common.go +++ b/modules/git/pipeline/lfs_common.go @@ -4,7 +4,6 @@ package pipeline import ( - "fmt" "time" "code.gitea.io/gitea/modules/git" @@ -26,7 +25,3 @@ type lfsResultSlice []*LFSResult func (a lfsResultSlice) Len() int { return len(a) } func (a lfsResultSlice) Swap(i, j int) { a[i], a[j] = a[j], a[i] } func (a lfsResultSlice) Less(i, j int) bool { return a[j].When.After(a[i].When) } - -func lfsError(msg string, err error) error { - return fmt.Errorf("LFS error occurred, %s: err: %w", msg, err) -} diff --git a/modules/git/pipeline/lfs_gogit.go b/modules/git/pipeline/lfs_gogit.go index adcf8ed09cb..c12397569cb 100644 --- a/modules/git/pipeline/lfs_gogit.go +++ b/modules/git/pipeline/lfs_gogit.go @@ -6,11 +6,10 @@ package pipeline import ( - "bufio" + "fmt" "io" "sort" "strings" - "sync" "code.gitea.io/gitea/modules/git" @@ -24,7 +23,6 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err resultsMap := map[string]*LFSResult{} results := make([]*LFSResult, 0) - basePath := repo.Path gogitRepo := repo.GoGitRepo() commitsIter, err := gogitRepo.Log(&gogit.LogOptions{ @@ -32,7 +30,7 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err All: true, }) if err != nil { - return nil, lfsError("failed to get GoGit CommitsIter", err) + return nil, fmt.Errorf("LFS error occurred, failed to get GoGit CommitsIter: err: %w", err) } err = commitsIter.ForEach(func(gitCommit *object.Commit) error { @@ -66,7 +64,7 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err return nil }) if err != nil && err != io.EOF { - return nil, lfsError("failure in CommitIter.ForEach", err) + return nil, fmt.Errorf("LFS error occurred, failure in CommitIter.ForEach: %w", err) } for _, result := range resultsMap { @@ -82,65 +80,6 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err } sort.Sort(lfsResultSlice(results)) - - // Should really use a go-git function here but name-rev is not completed and recapitulating it is not simple - shasToNameReader, shasToNameWriter := io.Pipe() - nameRevStdinReader, nameRevStdinWriter := io.Pipe() - errChan := make(chan error, 1) - wg := sync.WaitGroup{} - wg.Add(3) - - go func() { - defer wg.Done() - scanner := bufio.NewScanner(nameRevStdinReader) - i := 0 - for scanner.Scan() { - line := scanner.Text() - if len(line) == 0 { - continue - } - result := results[i] - result.FullCommitName = line - result.BranchName = strings.Split(line, "~")[0] - i++ - } - }() - go NameRevStdin(repo.Ctx, shasToNameReader, nameRevStdinWriter, &wg, basePath) - go func() { - defer wg.Done() - defer shasToNameWriter.Close() - for _, result := range results { - i := 0 - if i < len(result.SHA) { - n, err := shasToNameWriter.Write([]byte(result.SHA)[i:]) - if err != nil { - errChan <- err - break - } - i += n - } - n := 0 - for n < 1 { - n, err = shasToNameWriter.Write([]byte{'\n'}) - if err != nil { - errChan <- err - break - } - - } - - } - }() - - wg.Wait() - - select { - case err, has := <-errChan: - if has { - return nil, lfsError("unable to obtain name for LFS files", err) - } - default: - } - - return results, nil + err = fillResultNameRev(repo.Ctx, repo.Path, results) + return results, err } diff --git a/modules/git/pipeline/lfs_nogogit.go b/modules/git/pipeline/lfs_nogogit.go index d2f147854d9..91bda0d0e5e 100644 --- a/modules/git/pipeline/lfs_nogogit.go +++ b/modules/git/pipeline/lfs_nogogit.go @@ -8,46 +8,34 @@ package pipeline import ( "bufio" "bytes" + "encoding/hex" "io" "sort" "strings" - "sync" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git/gitcmd" ) // FindLFSFile finds commits that contain a provided pointer file hash -func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, error) { +func FindLFSFile(repo *git.Repository, objectID git.ObjectID) (results []*LFSResult, _ error) { + cmd := gitcmd.NewCommand("rev-list", "--all") + revListReader, revListReaderClose := cmd.MakeStdoutPipe() + defer revListReaderClose() + err := cmd.WithDir(repo.Path). + WithPipelineFunc(func(context gitcmd.Context) (err error) { + results, err = findLFSFileFunc(repo, objectID, revListReader) + return err + }).RunWithStderr(repo.Ctx) + return results, err +} + +func findLFSFileFunc(repo *git.Repository, objectID git.ObjectID, revListReader io.Reader) ([]*LFSResult, error) { resultsMap := map[string]*LFSResult{} results := make([]*LFSResult, 0) - - basePath := repo.Path - - // Use rev-list to provide us with all commits in order - revListReader, revListWriter := io.Pipe() - defer func() { - _ = revListWriter.Close() - _ = revListReader.Close() - }() - - go func() { - stderr := strings.Builder{} - err := gitcmd.NewCommand("rev-list", "--all").Run(repo.Ctx, &gitcmd.RunOpts{ - Dir: repo.Path, - Stdout: revListWriter, - Stderr: &stderr, - }) - if err != nil { - _ = revListWriter.CloseWithError(gitcmd.ConcatenateError(err, (&stderr).String())) - } else { - _ = revListWriter.Close() - } - }() - // Next feed the commits in order into cat-file --batch, followed by their trees and sub trees as necessary. // so let's create a batch stdin and stdout - batchStdinWriter, batchReader, cancel, err := repo.CatFileBatch(repo.Ctx) + batch, cancel, err := repo.CatFileBatch(repo.Ctx) if err != nil { return nil, err } @@ -55,7 +43,7 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err // We'll use a scanner for the revList because it's simpler than a bufio.Reader scan := bufio.NewScanner(revListReader) - trees := [][]byte{} + trees := []string{} paths := []string{} fnameBuf := make([]byte, 4096) @@ -64,14 +52,10 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err for scan.Scan() { // Get the next commit ID - commitID := scan.Bytes() + commitID := scan.Text() // push the commit to the cat-file --batch process - _, err := batchStdinWriter.Write(commitID) - if err != nil { - return nil, err - } - _, err = batchStdinWriter.Write([]byte{'\n'}) + info, batchReader, err := batch.QueryContent(commitID) if err != nil { return nil, err } @@ -81,26 +65,20 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err commitReadingLoop: for { - _, typ, size, err := git.ReadBatchLine(batchReader) - if err != nil { - return nil, err - } - - switch typ { + switch info.Type { case "tag": // This shouldn't happen but if it does well just get the commit and try again - id, err := git.ReadTagObjectID(batchReader, size) + id, err := git.ReadTagObjectID(batchReader, info.Size) if err != nil { return nil, err } - _, err = batchStdinWriter.Write([]byte(id + "\n")) - if err != nil { + if info, batchReader, err = batch.QueryContent(id); err != nil { return nil, err } continue case "commit": // Read in the commit to get its tree and in case this is one of the last used commits - curCommit, err = git.CommitFromReader(repo, git.MustIDFromString(string(commitID)), io.LimitReader(batchReader, size)) + curCommit, err = git.CommitFromReader(repo, git.MustIDFromString(commitID), io.LimitReader(batchReader, info.Size)) if err != nil { return nil, err } @@ -108,13 +86,13 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err return nil, err } - if _, err := batchStdinWriter.Write([]byte(curCommit.Tree.ID.String() + "\n")); err != nil { + if info, _, err = batch.QueryContent(curCommit.Tree.ID.String()); err != nil { return nil, err } curPath = "" case "tree": var n int64 - for n < size { + for n < info.Size { mode, fname, binObjectID, count, err := git.ParseCatFileTreeLine(objectID.Type(), batchReader, modeBuf, fnameBuf, workingShaBuf) if err != nil { return nil, err @@ -130,9 +108,7 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err } resultsMap[curCommit.ID.String()+":"+curPath+string(fname)] = &result } else if string(mode) == git.EntryModeTree.String() { - hexObjectID := make([]byte, objectID.Type().FullLength()) - git.BinToHex(objectID.Type(), binObjectID, hexObjectID) - trees = append(trees, hexObjectID) + trees = append(trees, hex.EncodeToString(binObjectID)) paths = append(paths, curPath+string(fname)+"/") } } @@ -140,11 +116,7 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err return nil, err } if len(trees) > 0 { - _, err := batchStdinWriter.Write(trees[len(trees)-1]) - if err != nil { - return nil, err - } - _, err = batchStdinWriter.Write([]byte("\n")) + info, _, err = batch.QueryContent(trees[len(trees)-1]) if err != nil { return nil, err } @@ -155,7 +127,7 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err break commitReadingLoop } default: - if err := git.DiscardFull(batchReader, size+1); err != nil { + if err := git.DiscardFull(batchReader, info.Size+1); err != nil { return nil, err } } @@ -179,56 +151,6 @@ func FindLFSFile(repo *git.Repository, objectID git.ObjectID) ([]*LFSResult, err } sort.Sort(lfsResultSlice(results)) - - // Should really use a go-git function here but name-rev is not completed and recapitulating it is not simple - shasToNameReader, shasToNameWriter := io.Pipe() - nameRevStdinReader, nameRevStdinWriter := io.Pipe() - errChan := make(chan error, 1) - wg := sync.WaitGroup{} - wg.Add(3) - - go func() { - defer wg.Done() - scanner := bufio.NewScanner(nameRevStdinReader) - i := 0 - for scanner.Scan() { - line := scanner.Text() - if len(line) == 0 { - continue - } - result := results[i] - result.FullCommitName = line - result.BranchName = strings.Split(line, "~")[0] - i++ - } - }() - go NameRevStdin(repo.Ctx, shasToNameReader, nameRevStdinWriter, &wg, basePath) - go func() { - defer wg.Done() - defer shasToNameWriter.Close() - for _, result := range results { - _, err := shasToNameWriter.Write([]byte(result.SHA)) - if err != nil { - errChan <- err - break - } - _, err = shasToNameWriter.Write([]byte{'\n'}) - if err != nil { - errChan <- err - break - } - } - }() - - wg.Wait() - - select { - case err, has := <-errChan: - if has { - return nil, lfsError("unable to obtain name for LFS files", err) - } - default: - } - - return results, nil + err = fillResultNameRev(repo.Ctx, repo.Path, results) + return results, err } diff --git a/modules/git/pipeline/lfs_test.go b/modules/git/pipeline/lfs_test.go new file mode 100644 index 00000000000..30fe2f93c29 --- /dev/null +++ b/modules/git/pipeline/lfs_test.go @@ -0,0 +1,38 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pipeline + +import ( + "testing" + "time" + + "code.gitea.io/gitea/modules/git" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFindLFSFile(t *testing.T) { + repoPath := "../../../tests/gitea-repositories-meta/user2/lfs.git" + gitRepo, err := git.OpenRepository(t.Context(), repoPath) + require.NoError(t, err) + defer gitRepo.Close() + + objectID := git.MustIDFromString("2b6c6c4eaefa24b22f2092c3d54b263ff26feb58") + + stats, err := FindLFSFile(gitRepo, objectID) + require.NoError(t, err) + + tm, err := time.Parse(time.RFC3339, "2022-12-21T17:56:42-05:00") + require.NoError(t, err) + + assert.Len(t, stats, 1) + assert.Equal(t, "CONTRIBUTING.md", stats[0].Name) + assert.Equal(t, "73cf03db6ece34e12bf91e8853dc58f678f2f82d", stats[0].SHA) + assert.Equal(t, "Initial commit", stats[0].Summary) + assert.Equal(t, tm, stats[0].When) + assert.Empty(t, stats[0].ParentHashes) + assert.Equal(t, "master", stats[0].BranchName) + assert.Equal(t, "master", stats[0].FullCommitName) +} diff --git a/modules/git/pipeline/main_test.go b/modules/git/pipeline/main_test.go new file mode 100644 index 00000000000..fa5832b68cc --- /dev/null +++ b/modules/git/pipeline/main_test.go @@ -0,0 +1,14 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pipeline + +import ( + "testing" + + "code.gitea.io/gitea/modules/git" +) + +func TestMain(m *testing.M) { + git.RunGitTests(m) +} diff --git a/modules/git/pipeline/namerev.go b/modules/git/pipeline/namerev.go index 0081f7a26db..24de442940f 100644 --- a/modules/git/pipeline/namerev.go +++ b/modules/git/pipeline/namerev.go @@ -4,30 +4,54 @@ package pipeline import ( - "bytes" + "bufio" "context" - "fmt" - "io" + "errors" "strings" - "sync" "code.gitea.io/gitea/modules/git/gitcmd" + + "golang.org/x/sync/errgroup" ) -// NameRevStdin runs name-rev --stdin -func NameRevStdin(ctx context.Context, shasToNameReader *io.PipeReader, nameRevStdinWriter *io.PipeWriter, wg *sync.WaitGroup, tmpBasePath string) { - defer wg.Done() - defer shasToNameReader.Close() - defer nameRevStdinWriter.Close() +func fillResultNameRev(ctx context.Context, basePath string, results []*LFSResult) error { + // Should really use a go-git function here but name-rev is not completed and recapitulating it is not simple + wg := errgroup.Group{} + cmd := gitcmd.NewCommand("name-rev", "--stdin", "--name-only", "--always").WithDir(basePath) + stdin, stdinClose := cmd.MakeStdinPipe() + stdout, stdoutClose := cmd.MakeStdoutPipe() + defer stdinClose() + defer stdoutClose() - stderr := new(bytes.Buffer) - var errbuf strings.Builder - if err := gitcmd.NewCommand("name-rev", "--stdin", "--name-only", "--always").Run(ctx, &gitcmd.RunOpts{ - Dir: tmpBasePath, - Stdout: nameRevStdinWriter, - Stdin: shasToNameReader, - Stderr: stderr, - }); err != nil { - _ = shasToNameReader.CloseWithError(fmt.Errorf("git name-rev [%s]: %w - %s", tmpBasePath, err, errbuf.String())) - } + wg.Go(func() error { + scanner := bufio.NewScanner(stdout) + i := 0 + for scanner.Scan() { + line := scanner.Text() + if len(line) == 0 { + continue + } + result := results[i] + result.FullCommitName = line + result.BranchName = strings.Split(line, "~")[0] + i++ + } + return scanner.Err() + }) + wg.Go(func() error { + defer stdinClose() + for _, result := range results { + _, err := stdin.Write([]byte(result.SHA)) + if err != nil { + return err + } + _, err = stdin.Write([]byte{'\n'}) + if err != nil { + return err + } + } + return nil + }) + err := cmd.RunWithStderr(ctx) + return errors.Join(err, wg.Wait()) } diff --git a/modules/git/pipeline/revlist.go b/modules/git/pipeline/revlist.go index 9d4ff754341..28d4751bd82 100644 --- a/modules/git/pipeline/revlist.go +++ b/modules/git/pipeline/revlist.go @@ -5,65 +5,26 @@ package pipeline import ( "bufio" - "bytes" "context" - "fmt" "io" "strings" - "sync" "code.gitea.io/gitea/modules/git/gitcmd" - "code.gitea.io/gitea/modules/log" ) -// RevListAllObjects runs rev-list --objects --all and writes to a pipewriter -func RevListAllObjects(ctx context.Context, revListWriter *io.PipeWriter, wg *sync.WaitGroup, basePath string, errChan chan<- error) { - defer wg.Done() - defer revListWriter.Close() - - stderr := new(bytes.Buffer) - var errbuf strings.Builder - cmd := gitcmd.NewCommand("rev-list", "--objects", "--all") - if err := cmd.Run(ctx, &gitcmd.RunOpts{ - Dir: basePath, - Stdout: revListWriter, - Stderr: stderr, - }); err != nil { - log.Error("git rev-list --objects --all [%s]: %v - %s", basePath, err, errbuf.String()) - err = fmt.Errorf("git rev-list --objects --all [%s]: %w - %s", basePath, err, errbuf.String()) - _ = revListWriter.CloseWithError(err) - errChan <- err - } -} - // RevListObjects run rev-list --objects from headSHA to baseSHA -func RevListObjects(ctx context.Context, revListWriter *io.PipeWriter, wg *sync.WaitGroup, tmpBasePath, headSHA, baseSHA string, errChan chan<- error) { - defer wg.Done() - defer revListWriter.Close() - stderr := new(bytes.Buffer) - var errbuf strings.Builder - cmd := gitcmd.NewCommand("rev-list", "--objects").AddDynamicArguments(headSHA) +func RevListObjects(ctx context.Context, cmd *gitcmd.Command, tmpBasePath, headSHA, baseSHA string) error { + cmd.AddArguments("rev-list", "--objects").AddDynamicArguments(headSHA) if baseSHA != "" { cmd = cmd.AddArguments("--not").AddDynamicArguments(baseSHA) } - if err := cmd.Run(ctx, &gitcmd.RunOpts{ - Dir: tmpBasePath, - Stdout: revListWriter, - Stderr: stderr, - }); err != nil { - log.Error("git rev-list [%s]: %v - %s", tmpBasePath, err, errbuf.String()) - errChan <- fmt.Errorf("git rev-list [%s]: %w - %s", tmpBasePath, err, errbuf.String()) - } + return cmd.WithDir(tmpBasePath).RunWithStderr(ctx) } // BlobsFromRevListObjects reads a RevListAllObjects and only selects blobs -func BlobsFromRevListObjects(revListReader *io.PipeReader, shasToCheckWriter *io.PipeWriter, wg *sync.WaitGroup) { - defer wg.Done() - defer revListReader.Close() - scanner := bufio.NewScanner(revListReader) - defer func() { - _ = shasToCheckWriter.CloseWithError(scanner.Err()) - }() +func BlobsFromRevListObjects(in io.ReadCloser, out io.WriteCloser) error { + defer out.Close() + scanner := bufio.NewScanner(in) for scanner.Scan() { line := scanner.Text() if len(line) == 0 { @@ -75,12 +36,12 @@ func BlobsFromRevListObjects(revListReader *io.PipeReader, shasToCheckWriter *io } toWrite := []byte(fields[0] + "\n") for len(toWrite) > 0 { - n, err := shasToCheckWriter.Write(toWrite) + n, err := out.Write(toWrite) if err != nil { - _ = revListReader.CloseWithError(err) - break + return err } toWrite = toWrite[n:] } } + return scanner.Err() } diff --git a/modules/git/ref.go b/modules/git/ref.go index 56b2db858ad..7b63d06b388 100644 --- a/modules/git/ref.go +++ b/modules/git/ref.go @@ -220,3 +220,14 @@ func (ref RefName) RefWebLinkPath() string { } return string(refType) + "/" + util.PathEscapeSegments(ref.ShortName()) } + +func ParseRefSuffix(ref string) (string, string) { + // Partially support https://git-scm.com/docs/gitrevisions + if idx := strings.Index(ref, "@{"); idx != -1 { + return ref[:idx], ref[idx:] + } + if idx := strings.Index(ref, "^"); idx != -1 { + return ref[:idx], ref[idx:] + } + return ref, "" +} diff --git a/modules/git/remote.go b/modules/git/remote.go index 05f006daf85..01e3d73c0e2 100644 --- a/modules/git/remote.go +++ b/modules/git/remote.go @@ -23,7 +23,7 @@ func GetRemoteAddress(ctx context.Context, repoPath, remoteName string) (string, cmd = gitcmd.NewCommand("config", "--get").AddDynamicArguments("remote." + remoteName + ".url") } - result, _, err := cmd.RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath}) + result, _, err := cmd.WithDir(repoPath).RunStdString(ctx) if err != nil { return "", err } @@ -75,9 +75,9 @@ func (err *ErrInvalidCloneAddr) Unwrap() error { func IsRemoteNotExistError(err error) bool { // see: https://github.com/go-gitea/gitea/issues/32889#issuecomment-2571848216 // Should not add space in the end, sometimes git will add a `:` - prefix1 := "exit status 128 - fatal: No such remote" // git < 2.30 - prefix2 := "exit status 2 - error: No such remote" // git >= 2.30 - return strings.HasPrefix(err.Error(), prefix1) || strings.HasPrefix(err.Error(), prefix2) + prefix1 := "fatal: No such remote" // git < 2.30, exit status 128 + prefix2 := "error: No such remote" // git >= 2.30. exit status 2 + return gitcmd.StderrHasPrefix(err, prefix1) || gitcmd.StderrHasPrefix(err, prefix2) } // normalizeSSHURL converts SSH-SCP format URLs to standard ssh:// format for security diff --git a/modules/git/repo.go b/modules/git/repo.go index 832164fe593..64671a73c93 100644 --- a/modules/git/repo.go +++ b/modules/git/repo.go @@ -8,42 +8,24 @@ import ( "bytes" "context" "fmt" - "io" "net/url" "os" "path" - "path/filepath" "strconv" "strings" "time" "code.gitea.io/gitea/modules/git/gitcmd" "code.gitea.io/gitea/modules/proxy" - "code.gitea.io/gitea/modules/setting" ) -// GPGSettings represents the default GPG settings for this repository -type GPGSettings struct { - Sign bool - KeyID string - Email string - Name string - PublicKeyContent string - Format string -} - const prettyLogFormat = `--pretty=format:%H` -// GetAllCommitsCount returns count of all commits in repository -func (repo *Repository) GetAllCommitsCount() (int64, error) { - return AllCommitsCount(repo.Ctx, repo.Path, false) -} - func (repo *Repository) ShowPrettyFormatLogToList(ctx context.Context, revisionRange string) ([]*Commit, error) { // avoid: ambiguous argument 'refs/a...refs/b': unknown revision or path not in the working tree. Use '--': 'git [...] -- [...]' logs, _, err := gitcmd.NewCommand("log").AddArguments(prettyLogFormat). - AddDynamicArguments(revisionRange).AddArguments("--"). - RunStdBytes(ctx, &gitcmd.RunOpts{Dir: repo.Path}) + AddDynamicArguments(revisionRange).AddArguments("--").WithDir(repo.Path). + RunStdBytes(ctx) if err != nil { return nil, err } @@ -71,7 +53,7 @@ func (repo *Repository) parsePrettyFormatLogToList(logs []byte) ([]*Commit, erro // IsRepoURLAccessible checks if given repository URL is accessible. func IsRepoURLAccessible(ctx context.Context, url string) bool { - _, _, err := gitcmd.NewCommand("ls-remote", "-q", "-h").AddDynamicArguments(url, "HEAD").RunStdString(ctx, nil) + _, _, err := gitcmd.NewCommand("ls-remote", "-q", "-h").AddDynamicArguments(url, "HEAD").RunStdString(ctx) return err == nil } @@ -94,27 +76,25 @@ func InitRepository(ctx context.Context, repoPath string, bare bool, objectForma if bare { cmd.AddArguments("--bare") } - _, _, err = cmd.RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath}) + _, _, err = cmd.WithDir(repoPath).RunStdString(ctx) return err } // IsEmpty Check if repository is empty. func (repo *Repository) IsEmpty() (bool, error) { - var errbuf, output strings.Builder - if err := gitcmd.NewCommand().AddOptionFormat("--git-dir=%s", repo.Path).AddArguments("rev-list", "-n", "1", "--all"). - Run(repo.Ctx, &gitcmd.RunOpts{ - Dir: repo.Path, - Stdout: &output, - Stderr: &errbuf, - }); err != nil { - if (err.Error() == "exit status 1" && strings.TrimSpace(errbuf.String()) == "") || err.Error() == "exit status 129" { + stdout, _, err := gitcmd.NewCommand(). + AddOptionFormat("--git-dir=%s", repo.Path). + AddArguments("rev-list", "-n", "1", "--all"). + WithDir(repo.Path). + RunStdString(repo.Ctx) + if err != nil { + if (gitcmd.IsErrorExitCode(err, 1) && err.Stderr() == "") || gitcmd.IsErrorExitCode(err, 129) { // git 2.11 exits with 129 if the repo is empty return true, nil } - return true, fmt.Errorf("check empty: %w - %s", err, errbuf.String()) + return true, fmt.Errorf("check empty: %w", err) } - - return strings.TrimSpace(output.String()) == "", nil + return strings.TrimSpace(stdout) == "", nil } // CloneRepoOptions options when clone a repository @@ -130,6 +110,8 @@ type CloneRepoOptions struct { Filter string SkipTLSVerify bool SSHAuthSock string + SingleBranch bool + Env []string } // Clone clones original repository to target path. @@ -164,6 +146,9 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error { if opts.Filter != "" { cmd.AddArguments("--filter").AddDynamicArguments(opts.Filter) } + if opts.SingleBranch { + cmd.AddArguments("--single-branch") + } if len(opts.Branch) > 0 { cmd.AddArguments("-b").AddDynamicArguments(opts.Branch) } @@ -174,39 +159,41 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error { } envs := os.Environ() - u, err := url.Parse(from) - if err == nil { - envs = proxy.EnvWithProxy(u) + if opts.Env != nil { + envs = opts.Env + } else { + u, err := url.Parse(from) + if err == nil { + envs = proxy.EnvWithProxy(u) + } } - stderr := new(bytes.Buffer) - if err = cmd.Run(ctx, &gitcmd.RunOpts{ - Timeout: opts.Timeout, - Env: envs, - Stdout: io.Discard, - Stderr: stderr, - SSHAuthSock: opts.SSHAuthSock, - }); err != nil { - return gitcmd.ConcatenateError(err, stderr.String()) - } - return nil + return cmd. + WithTimeout(opts.Timeout). + WithEnv(envs). + WithSSHAuthSock(opts.SSHAuthSock). + RunWithStderr(ctx) } // PushOptions options when push to remote type PushOptions struct { - Remote string - Branch string - Force bool - Mirror bool - Env []string - Timeout time.Duration - SSHAuthSock string + Remote string + LocalRefName string + Branch string + Force bool + ForceWithLease string + Mirror bool + Env []string + Timeout time.Duration + SSHAuthSock string } // Push pushs local commits to given remote branch. func Push(ctx context.Context, repoPath string, opts PushOptions) error { cmd := gitcmd.NewCommand("push") - if opts.Force { + if opts.ForceWithLease != "" { + cmd.AddOptionFormat("--force-with-lease=%s", opts.ForceWithLease) + } else if opts.Force { cmd.AddArguments("-f") } if opts.Mirror { @@ -214,16 +201,17 @@ func Push(ctx context.Context, repoPath string, opts PushOptions) error { } remoteBranchArgs := []string{opts.Remote} if len(opts.Branch) > 0 { - remoteBranchArgs = append(remoteBranchArgs, opts.Branch) + var refspec string + if opts.LocalRefName != "" { + refspec = fmt.Sprintf("%s:%s", opts.LocalRefName, opts.Branch) + } else { + refspec = opts.Branch + } + remoteBranchArgs = append(remoteBranchArgs, refspec) } cmd.AddDashesAndList(remoteBranchArgs...) - stdout, stderr, err := cmd.RunStdString(ctx, &gitcmd.RunOpts{ - Env: opts.Env, - Timeout: opts.Timeout, - Dir: repoPath, - SSHAuthSock: opts.SSHAuthSock, - }) + stdout, stderr, err := cmd.WithEnv(opts.Env).WithTimeout(opts.Timeout).WithDir(repoPath).WithSSHAuthSock(opts.SSHAuthSock).RunStdString(ctx) if err != nil { if strings.Contains(stderr, "non-fast-forward") { return &ErrPushOutOfDate{StdOut: stdout, StdErr: stderr, Err: err} @@ -239,84 +227,3 @@ func Push(ctx context.Context, repoPath string, opts PushOptions) error { return nil } - -// GetLatestCommitTime returns time for latest commit in repository (across all branches) -func GetLatestCommitTime(ctx context.Context, repoPath string) (time.Time, error) { - cmd := gitcmd.NewCommand("for-each-ref", "--sort=-committerdate", BranchPrefix, "--count", "1", "--format=%(committerdate)") - stdout, _, err := cmd.RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath}) - if err != nil { - return time.Time{}, err - } - commitTime := strings.TrimSpace(stdout) - return time.Parse("Mon Jan _2 15:04:05 2006 -0700", commitTime) -} - -// DivergeObject represents commit count diverging commits -type DivergeObject struct { - Ahead int - Behind int -} - -// GetDivergingCommits returns the number of commits a targetBranch is ahead or behind a baseBranch -func GetDivergingCommits(ctx context.Context, repoPath, baseBranch, targetBranch string) (do DivergeObject, err error) { - cmd := gitcmd.NewCommand("rev-list", "--count", "--left-right"). - AddDynamicArguments(baseBranch + "..." + targetBranch).AddArguments("--") - stdout, _, err := cmd.RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath}) - if err != nil { - return do, err - } - left, right, found := strings.Cut(strings.Trim(stdout, "\n"), "\t") - if !found { - return do, fmt.Errorf("git rev-list output is missing a tab: %q", stdout) - } - - do.Behind, err = strconv.Atoi(left) - if err != nil { - return do, err - } - do.Ahead, err = strconv.Atoi(right) - if err != nil { - return do, err - } - return do, nil -} - -// CreateBundle create bundle content to the target path -func (repo *Repository) CreateBundle(ctx context.Context, commit string, out io.Writer) error { - tmp, cleanup, err := setting.AppDataTempDir("git-repo-content").MkdirTempRandom("gitea-bundle") - if err != nil { - return err - } - defer cleanup() - - env := append(os.Environ(), "GIT_OBJECT_DIRECTORY="+filepath.Join(repo.Path, "objects")) - _, _, err = gitcmd.NewCommand("init", "--bare").RunStdString(ctx, &gitcmd.RunOpts{Dir: tmp, Env: env}) - if err != nil { - return err - } - - _, _, err = gitcmd.NewCommand("reset", "--soft").AddDynamicArguments(commit).RunStdString(ctx, &gitcmd.RunOpts{Dir: tmp, Env: env}) - if err != nil { - return err - } - - _, _, err = gitcmd.NewCommand("branch", "-m", "bundle").RunStdString(ctx, &gitcmd.RunOpts{Dir: tmp, Env: env}) - if err != nil { - return err - } - - tmpFile := filepath.Join(tmp, "bundle") - _, _, err = gitcmd.NewCommand("bundle", "create").AddDynamicArguments(tmpFile, "bundle", "HEAD").RunStdString(ctx, &gitcmd.RunOpts{Dir: tmp, Env: env}) - if err != nil { - return err - } - - fi, err := os.Open(tmpFile) - if err != nil { - return err - } - defer fi.Close() - - _, err = io.Copy(out, fi) - return err -} diff --git a/modules/git/repo_archive.go b/modules/git/repo_archive.go deleted file mode 100644 index e12300345f1..00000000000 --- a/modules/git/repo_archive.go +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright 2015 The Gogs Authors. All rights reserved. -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package git - -import ( - "context" - "fmt" - "io" - "path/filepath" - "strings" - - "code.gitea.io/gitea/modules/git/gitcmd" -) - -// ArchiveType archive types -type ArchiveType int - -const ( - ArchiveUnknown ArchiveType = iota - ArchiveZip // 1 - ArchiveTarGz // 2 - ArchiveBundle // 3 -) - -// String converts an ArchiveType to string: the extension of the archive file without prefix dot -func (a ArchiveType) String() string { - switch a { - case ArchiveZip: - return "zip" - case ArchiveTarGz: - return "tar.gz" - case ArchiveBundle: - return "bundle" - } - return "unknown" -} - -func SplitArchiveNameType(s string) (string, ArchiveType) { - switch { - case strings.HasSuffix(s, ".zip"): - return strings.TrimSuffix(s, ".zip"), ArchiveZip - case strings.HasSuffix(s, ".tar.gz"): - return strings.TrimSuffix(s, ".tar.gz"), ArchiveTarGz - case strings.HasSuffix(s, ".bundle"): - return strings.TrimSuffix(s, ".bundle"), ArchiveBundle - } - return s, ArchiveUnknown -} - -// CreateArchive create archive content to the target path -func (repo *Repository) CreateArchive(ctx context.Context, format ArchiveType, target io.Writer, usePrefix bool, commitID string) error { - if format.String() == "unknown" { - return fmt.Errorf("unknown format: %v", format) - } - - cmd := gitcmd.NewCommand("archive") - if usePrefix { - cmd.AddOptionFormat("--prefix=%s", filepath.Base(strings.TrimSuffix(repo.Path, ".git"))+"/") - } - cmd.AddOptionFormat("--format=%s", format.String()) - cmd.AddDynamicArguments(commitID) - - var stderr strings.Builder - err := cmd.Run(ctx, &gitcmd.RunOpts{ - Dir: repo.Path, - Stdout: target, - Stderr: &stderr, - }) - if err != nil { - return gitcmd.ConcatenateError(err, stderr.String()) - } - return nil -} diff --git a/modules/git/repo_base_gogit.go b/modules/git/repo_base_gogit.go index e0d0b45372b..986264fd936 100644 --- a/modules/git/repo_base_gogit.go +++ b/modules/git/repo_base_gogit.go @@ -32,7 +32,6 @@ type Repository struct { gogitRepo *gogit.Repository gogitStorage *filesystem.Storage - gpgSettings *GPGSettings Ctx context.Context LastCommitCache *LastCommitCache diff --git a/modules/git/repo_base_nogogit.go b/modules/git/repo_base_nogogit.go index 4091e708465..775bbd4a09c 100644 --- a/modules/git/repo_base_nogogit.go +++ b/modules/git/repo_base_nogogit.go @@ -7,9 +7,9 @@ package git import ( - "bufio" "context" "path/filepath" + "sync" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/util" @@ -23,13 +23,9 @@ type Repository struct { tagCache *ObjectCache[*Tag] - gpgSettings *GPGSettings - - batchInUse bool - batch *Batch - - checkInUse bool - check *Batch + mu sync.Mutex + catFileBatchCloser CatFileBatchCloser + catFileBatchInUse bool Ctx context.Context LastCommitCache *LastCommitCache @@ -58,69 +54,47 @@ func OpenRepository(ctx context.Context, repoPath string) (*Repository, error) { }, nil } -// CatFileBatch obtains a CatFileBatch for this repository -func (repo *Repository) CatFileBatch(ctx context.Context) (WriteCloserError, *bufio.Reader, func(), error) { - if repo.batch == nil { - var err error - repo.batch, err = NewBatch(ctx, repo.Path) +// CatFileBatch obtains a "batch object provider" for this repository. +// It reuses an existing one if available, otherwise creates a new one. +func (repo *Repository) CatFileBatch(ctx context.Context) (_ CatFileBatch, closeFunc func(), err error) { + repo.mu.Lock() + defer repo.mu.Unlock() + + if repo.catFileBatchCloser == nil { + repo.catFileBatchCloser, err = NewBatch(ctx, repo.Path) if err != nil { - return nil, nil, nil, err + repo.catFileBatchCloser = nil // otherwise it is "interface(nil)" and will cause wrong logic + return nil, nil, err } } - if !repo.batchInUse { - repo.batchInUse = true - return repo.batch.Writer, repo.batch.Reader, func() { - repo.batchInUse = false + if !repo.catFileBatchInUse { + repo.catFileBatchInUse = true + return CatFileBatch(repo.catFileBatchCloser), func() { + repo.mu.Lock() + defer repo.mu.Unlock() + repo.catFileBatchInUse = false }, nil } log.Debug("Opening temporary cat file batch for: %s", repo.Path) tempBatch, err := NewBatch(ctx, repo.Path) if err != nil { - return nil, nil, nil, err + return nil, nil, err } - return tempBatch.Writer, tempBatch.Reader, tempBatch.Close, nil -} - -// CatFileBatchCheck obtains a CatFileBatchCheck for this repository -func (repo *Repository) CatFileBatchCheck(ctx context.Context) (WriteCloserError, *bufio.Reader, func(), error) { - if repo.check == nil { - var err error - repo.check, err = NewBatchCheck(ctx, repo.Path) - if err != nil { - return nil, nil, nil, err - } - } - - if !repo.checkInUse { - repo.checkInUse = true - return repo.check.Writer, repo.check.Reader, func() { - repo.checkInUse = false - }, nil - } - - log.Debug("Opening temporary cat file batch-check for: %s", repo.Path) - tempBatchCheck, err := NewBatchCheck(ctx, repo.Path) - if err != nil { - return nil, nil, nil, err - } - return tempBatchCheck.Writer, tempBatchCheck.Reader, tempBatchCheck.Close, nil + return tempBatch, tempBatch.Close, nil } func (repo *Repository) Close() error { if repo == nil { return nil } - if repo.batch != nil { - repo.batch.Close() - repo.batch = nil - repo.batchInUse = false - } - if repo.check != nil { - repo.check.Close() - repo.check = nil - repo.checkInUse = false + repo.mu.Lock() + defer repo.mu.Unlock() + if repo.catFileBatchCloser != nil { + repo.catFileBatchCloser.Close() + repo.catFileBatchCloser = nil + repo.catFileBatchInUse = false } repo.LastCommitCache = nil repo.tagCache = nil diff --git a/modules/git/repo_base_nogogit_test.go b/modules/git/repo_base_nogogit_test.go new file mode 100644 index 00000000000..a12bbb73c20 --- /dev/null +++ b/modules/git/repo_base_nogogit_test.go @@ -0,0 +1,26 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build !gogit + +package git + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRepoCatFileBatch(t *testing.T) { + t.Run("MissingRepoAndClose", func(t *testing.T) { + repo, err := OpenRepository(t.Context(), filepath.Join(testReposDir, "repo1_bare")) + require.NoError(t, err) + repo.Path = "/no-such" // when the repo is missing (it usually occurs during testing because the fixtures are synced frequently) + _, _, err = repo.CatFileBatch(t.Context()) + require.Error(t, err) + require.NoError(t, repo.Close()) // shouldn't panic + }) + + // TODO: test more methods and concurrency queries +} diff --git a/modules/git/repo_blame.go b/modules/git/repo_blame.go deleted file mode 100644 index 04f74049aab..00000000000 --- a/modules/git/repo_blame.go +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright 2017 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package git - -import ( - "fmt" - - "code.gitea.io/gitea/modules/git/gitcmd" -) - -// LineBlame returns the latest commit at the given line -func (repo *Repository) LineBlame(revision, path, file string, line uint) (*Commit, error) { - res, _, err := gitcmd.NewCommand("blame"). - AddOptionFormat("-L %d,%d", line, line). - AddOptionValues("-p", revision). - AddDashesAndList(file).RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: path}) - if err != nil { - return nil, err - } - if len(res) < 40 { - return nil, fmt.Errorf("invalid result of blame: %s", res) - } - return repo.GetCommit(res[:40]) -} diff --git a/modules/git/repo_blob.go b/modules/git/repo_blob.go index 698b6c7074a..ff930a34328 100644 --- a/modules/git/repo_blob.go +++ b/modules/git/repo_blob.go @@ -9,5 +9,11 @@ func (repo *Repository) GetBlob(idStr string) (*Blob, error) { if err != nil { return nil, err } - return repo.getBlob(id) + if id.IsZero() { + return nil, ErrNotExist{id.String(), ""} + } + return &Blob{ + ID: id, + repo: repo, + }, nil } diff --git a/modules/git/repo_blob_gogit.go b/modules/git/repo_blob_gogit.go deleted file mode 100644 index 66c8c2775ce..00000000000 --- a/modules/git/repo_blob_gogit.go +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright 2018 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -//go:build gogit - -package git - -import ( - "github.com/go-git/go-git/v5/plumbing" -) - -func (repo *Repository) getBlob(id ObjectID) (*Blob, error) { - encodedObj, err := repo.gogitRepo.Storer.EncodedObject(plumbing.AnyObject, plumbing.Hash(id.RawValue())) - if err != nil { - return nil, ErrNotExist{id.String(), ""} - } - - return &Blob{ - ID: id, - gogitEncodedObj: encodedObj, - }, nil -} diff --git a/modules/git/repo_blob_nogogit.go b/modules/git/repo_blob_nogogit.go deleted file mode 100644 index 04b0fb00ff7..00000000000 --- a/modules/git/repo_blob_nogogit.go +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -//go:build !gogit - -package git - -func (repo *Repository) getBlob(id ObjectID) (*Blob, error) { - if id.IsZero() { - return nil, ErrNotExist{id.String(), ""} - } - return &Blob{ - ID: id, - repo: repo, - }, nil -} diff --git a/modules/git/repo_branch.go b/modules/git/repo_branch.go index 5d4c05bb28a..1eebc72158b 100644 --- a/modules/git/repo_branch.go +++ b/modules/git/repo_branch.go @@ -5,84 +5,20 @@ package git import ( - "context" - "errors" - "strings" - "code.gitea.io/gitea/modules/git/gitcmd" ) // BranchPrefix base dir of the branch information file store on git const BranchPrefix = "refs/heads/" -// IsReferenceExist returns true if given reference exists in the repository. -func IsReferenceExist(ctx context.Context, repoPath, name string) bool { - _, _, err := gitcmd.NewCommand("show-ref", "--verify").AddDashesAndList(name).RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath}) - return err == nil -} - -// IsBranchExist returns true if given branch exists in the repository. -func IsBranchExist(ctx context.Context, repoPath, name string) bool { - return IsReferenceExist(ctx, repoPath, BranchPrefix+name) -} - -func GetDefaultBranch(ctx context.Context, repoPath string) (string, error) { - stdout, _, err := gitcmd.NewCommand("symbolic-ref", "HEAD").RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath}) - if err != nil { - return "", err - } - stdout = strings.TrimSpace(stdout) - if !strings.HasPrefix(stdout, BranchPrefix) { - return "", errors.New("the HEAD is not a branch: " + stdout) - } - return strings.TrimPrefix(stdout, BranchPrefix), nil -} - -// DeleteBranchOptions Option(s) for delete branch -type DeleteBranchOptions struct { - Force bool -} - -// DeleteBranch delete a branch by name on repository. -func (repo *Repository) DeleteBranch(name string, opts DeleteBranchOptions) error { - cmd := gitcmd.NewCommand("branch") - - if opts.Force { - cmd.AddArguments("-D") - } else { - cmd.AddArguments("-d") - } - - cmd.AddDashesAndList(name) - _, _, err := cmd.RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) - - return err -} - -// CreateBranch create a new branch -func (repo *Repository) CreateBranch(branch, oldbranchOrCommit string) error { - cmd := gitcmd.NewCommand("branch") - cmd.AddDashesAndList(branch, oldbranchOrCommit) - - _, _, err := cmd.RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) - - return err -} - // AddRemote adds a new remote to repository. func (repo *Repository) AddRemote(name, url string, fetch bool) error { cmd := gitcmd.NewCommand("remote", "add") if fetch { cmd.AddArguments("-f") } - cmd.AddDynamicArguments(name, url) - - _, _, err := cmd.RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) - return err -} - -// RenameBranch rename a branch -func (repo *Repository) RenameBranch(from, to string) error { - _, _, err := gitcmd.NewCommand("branch", "-m").AddDynamicArguments(from, to).RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) + _, _, err := cmd.AddDynamicArguments(name, url). + WithDir(repo.Path). + RunStdString(repo.Ctx) return err } diff --git a/modules/git/repo_branch_nogogit.go b/modules/git/repo_branch_nogogit.go index 255c2974e9b..f925aab3e4a 100644 --- a/modules/git/repo_branch_nogogit.go +++ b/modules/git/repo_branch_nogogit.go @@ -8,7 +8,6 @@ package git import ( "bufio" - "bytes" "context" "io" "strings" @@ -18,24 +17,24 @@ import ( ) // IsObjectExist returns true if the given object exists in the repository. +// FIXME: this function doesn't seem right, it is only used by GarbageCollectLFSMetaObjectsForRepo func (repo *Repository) IsObjectExist(name string) bool { if name == "" { return false } - wr, rd, cancel, err := repo.CatFileBatchCheck(repo.Ctx) + batch, cancel, err := repo.CatFileBatch(repo.Ctx) if err != nil { - log.Debug("Error writing to CatFileBatchCheck %v", err) + log.Debug("Error opening CatFileBatch %v", err) return false } defer cancel() - _, err = wr.Write([]byte(name + "\n")) + info, err := batch.QueryInfo(name) if err != nil { - log.Debug("Error writing to CatFileBatchCheck %v", err) + log.Debug("Error checking object info %v", err) return false } - sha, _, _, err := ReadBatchLine(rd) - return err == nil && bytes.HasPrefix(sha, []byte(strings.TrimSpace(name))) + return strings.HasPrefix(info.ID, name) // FIXME: this logic doesn't seem right, why "HasPrefix" } // IsReferenceExist returns true if given reference exists in the repository. @@ -44,18 +43,13 @@ func (repo *Repository) IsReferenceExist(name string) bool { return false } - wr, rd, cancel, err := repo.CatFileBatchCheck(repo.Ctx) + batch, cancel, err := repo.CatFileBatch(repo.Ctx) if err != nil { - log.Debug("Error writing to CatFileBatchCheck %v", err) + log.Error("Error opening CatFileBatch %v", err) return false } defer cancel() - _, err = wr.Write([]byte(name + "\n")) - if err != nil { - log.Debug("Error writing to CatFileBatchCheck %v", err) - return false - } - _, _, _, err = ReadBatchLine(rd) + _, err = batch.QueryInfo(name) return err == nil } @@ -100,94 +94,81 @@ func callShowRef(ctx context.Context, repoPath, trimPrefix string, extraArgs git } func WalkShowRef(ctx context.Context, repoPath string, extraArgs gitcmd.TrustedCmdArgs, skip, limit int, walkfn func(sha1, refname string) error) (countAll int, err error) { - stdoutReader, stdoutWriter := io.Pipe() - defer func() { - _ = stdoutReader.Close() - _ = stdoutWriter.Close() - }() - - go func() { - stderrBuilder := &strings.Builder{} - args := gitcmd.TrustedCmdArgs{"for-each-ref", "--format=%(objectname) %(refname)"} - args = append(args, extraArgs...) - err := gitcmd.NewCommand(args...).Run(ctx, &gitcmd.RunOpts{ - Dir: repoPath, - Stdout: stdoutWriter, - Stderr: stderrBuilder, - }) - if err != nil { - if stderrBuilder.Len() == 0 { - _ = stdoutWriter.Close() - return - } - _ = stdoutWriter.CloseWithError(gitcmd.ConcatenateError(err, stderrBuilder.String())) - } else { - _ = stdoutWriter.Close() - } - }() - i := 0 - bufReader := bufio.NewReader(stdoutReader) - for i < skip { - _, isPrefix, err := bufReader.ReadLine() - if err == io.EOF { - return i, nil - } - if err != nil { - return 0, err - } - if !isPrefix { - i++ - } + args := gitcmd.TrustedCmdArgs{"for-each-ref", "--format=%(objectname) %(refname)"} + args = append(args, extraArgs...) + cmd := gitcmd.NewCommand(args...) + stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe() + defer stdoutReaderClose() + cmd.WithDir(repoPath). + WithPipelineFunc(func(gitcmd.Context) error { + bufReader := bufio.NewReader(stdoutReader) + for i < skip { + _, isPrefix, err := bufReader.ReadLine() + if err == io.EOF { + return nil + } + if err != nil { + return err + } + if !isPrefix { + i++ + } + } + for limit == 0 || i < skip+limit { + // The output of show-ref is simply a list: + // SP LF + sha, err := bufReader.ReadString(' ') + if err == io.EOF { + return nil + } + if err != nil { + return err + } + + branchName, err := bufReader.ReadString('\n') + if err == io.EOF { + // This shouldn't happen... but we'll tolerate it for the sake of peace + return nil + } + if err != nil { + return err + } + + if len(branchName) > 0 { + branchName = branchName[:len(branchName)-1] + } + + if len(sha) > 0 { + sha = sha[:len(sha)-1] + } + + err = walkfn(sha, branchName) + if err != nil { + return err + } + i++ + } + // count all refs + for limit != 0 { + _, isPrefix, err := bufReader.ReadLine() + if err == io.EOF { + return nil + } + if err != nil { + return err + } + if !isPrefix { + i++ + } + } + return nil + }) + err = cmd.RunWithStderr(ctx) + if errPipeline, ok := gitcmd.UnwrapPipelineError(err); ok { + return i, errPipeline // keep the old behavior: return pipeline error directly } - for limit == 0 || i < skip+limit { - // The output of show-ref is simply a list: - // SP LF - sha, err := bufReader.ReadString(' ') - if err == io.EOF { - return i, nil - } - if err != nil { - return 0, err - } - - branchName, err := bufReader.ReadString('\n') - if err == io.EOF { - // This shouldn't happen... but we'll tolerate it for the sake of peace - return i, nil - } - if err != nil { - return i, err - } - - if len(branchName) > 0 { - branchName = branchName[:len(branchName)-1] - } - - if len(sha) > 0 { - sha = sha[:len(sha)-1] - } - - err = walkfn(sha, branchName) - if err != nil { - return i, err - } - i++ - } - // count all refs - for limit != 0 { - _, isPrefix, err := bufReader.ReadLine() - if err == io.EOF { - return i, nil - } - if err != nil { - return 0, err - } - if !isPrefix { - i++ - } - } - return i, nil + return i, err } // GetRefsBySha returns all references filtered with prefix that belong to a sha commit hash diff --git a/modules/git/repo_commit.go b/modules/git/repo_commit.go index 6e5911f1ddd..c10f73690cf 100644 --- a/modules/git/repo_commit.go +++ b/modules/git/repo_commit.go @@ -11,7 +11,6 @@ import ( "strconv" "strings" - "code.gitea.io/gitea/modules/cache" "code.gitea.io/gitea/modules/git/gitcmd" "code.gitea.io/gitea/modules/setting" ) @@ -60,7 +59,11 @@ func (repo *Repository) getCommitByPathWithID(id ObjectID, relpath string) (*Com relpath = `\` + relpath } - stdout, _, runErr := gitcmd.NewCommand("log", "-1", prettyLogFormat).AddDynamicArguments(id.String()).AddDashesAndList(relpath).RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) + stdout, _, runErr := gitcmd.NewCommand("log", "-1", prettyLogFormat). + AddDynamicArguments(id.String()). + AddDashesAndList(relpath). + WithDir(repo.Path). + RunStdString(repo.Ctx) if runErr != nil { return nil, runErr } @@ -75,7 +78,10 @@ func (repo *Repository) getCommitByPathWithID(id ObjectID, relpath string) (*Com // GetCommitByPath returns the last commit of relative path. func (repo *Repository) GetCommitByPath(relpath string) (*Commit, error) { - stdout, _, runErr := gitcmd.NewCommand("log", "-1", prettyLogFormat).AddDashesAndList(relpath).RunStdBytes(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) + stdout, _, runErr := gitcmd.NewCommand("log", "-1", prettyLogFormat). + AddDashesAndList(relpath). + WithDir(repo.Path). + RunStdBytes(repo.Ctx) if runErr != nil { return nil, runErr } @@ -108,7 +114,7 @@ func (repo *Repository) commitsByRangeWithTime(id ObjectID, page, pageSize int, cmd.AddOptionFormat("--until=%s", until) } - stdout, _, err := cmd.RunStdBytes(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) + stdout, _, err := cmd.WithDir(repo.Path).RunStdBytes(repo.Ctx) if err != nil { return nil, err } @@ -162,7 +168,7 @@ func (repo *Repository) searchCommits(id ObjectID, opts SearchCommitsOptions) ([ // search for commits matching given constraints and keywords in commit msg addCommonSearchArgs(cmd) - stdout, _, err := cmd.RunStdBytes(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) + stdout, _, err := cmd.WithDir(repo.Path).RunStdBytes(repo.Ctx) if err != nil { return nil, err } @@ -183,7 +189,7 @@ func (repo *Repository) searchCommits(id ObjectID, opts SearchCommitsOptions) ([ hashCmd.AddDynamicArguments(v) // search with given constraints for commit matching sha hash of v - hashMatching, _, err := hashCmd.RunStdBytes(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) + hashMatching, _, err := hashCmd.WithDir(repo.Path).RunStdBytes(repo.Ctx) if err != nil || bytes.Contains(stdout, hashMatching) { continue } @@ -198,23 +204,17 @@ func (repo *Repository) searchCommits(id ObjectID, opts SearchCommitsOptions) ([ // FileChangedBetweenCommits Returns true if the file changed between commit IDs id1 and id2 // You must ensure that id1 and id2 are valid commit ids. func (repo *Repository) FileChangedBetweenCommits(filename, id1, id2 string) (bool, error) { - stdout, _, err := gitcmd.NewCommand("diff", "--name-only", "-z").AddDynamicArguments(id1, id2).AddDashesAndList(filename).RunStdBytes(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) + stdout, _, err := gitcmd.NewCommand("diff", "--name-only", "-z"). + AddDynamicArguments(id1, id2). + AddDashesAndList(filename). + WithDir(repo.Path). + RunStdBytes(repo.Ctx) if err != nil { return false, err } return len(strings.TrimSpace(string(stdout))) > 0, nil } -// FileCommitsCount return the number of files at a revision -func (repo *Repository) FileCommitsCount(revision, file string) (int64, error) { - return CommitsCount(repo.Ctx, - CommitsCountOptions{ - RepoPath: repo.Path, - Revision: []string{revision}, - RelPath: []string{file}, - }) -} - type CommitsByFileAndRangeOptions struct { Revision string File string @@ -226,76 +226,70 @@ type CommitsByFileAndRangeOptions struct { // CommitsByFileAndRange return the commits according revision file and the page func (repo *Repository) CommitsByFileAndRange(opts CommitsByFileAndRangeOptions) ([]*Commit, error) { - stdoutReader, stdoutWriter := io.Pipe() - defer func() { - _ = stdoutReader.Close() - _ = stdoutWriter.Close() - }() - go func() { - stderr := strings.Builder{} - gitCmd := gitcmd.NewCommand("rev-list"). - AddOptionFormat("--max-count=%d", setting.Git.CommitsRangeSize). - AddOptionFormat("--skip=%d", (opts.Page-1)*setting.Git.CommitsRangeSize) - gitCmd.AddDynamicArguments(opts.Revision) + gitCmd := gitcmd.NewCommand("rev-list"). + AddOptionFormat("--max-count=%d", setting.Git.CommitsRangeSize). + AddOptionFormat("--skip=%d", (opts.Page-1)*setting.Git.CommitsRangeSize) + gitCmd.AddDynamicArguments(opts.Revision) - if opts.Not != "" { - gitCmd.AddOptionValues("--not", opts.Not) - } - if opts.Since != "" { - gitCmd.AddOptionFormat("--since=%s", opts.Since) - } - if opts.Until != "" { - gitCmd.AddOptionFormat("--until=%s", opts.Until) - } - - gitCmd.AddDashesAndList(opts.File) - err := gitCmd.Run(repo.Ctx, &gitcmd.RunOpts{ - Dir: repo.Path, - Stdout: stdoutWriter, - Stderr: &stderr, - }) - if err != nil { - _ = stdoutWriter.CloseWithError(gitcmd.ConcatenateError(err, (&stderr).String())) - } else { - _ = stdoutWriter.Close() - } - }() - - objectFormat, err := repo.GetObjectFormat() - if err != nil { - return nil, err + if opts.Not != "" { + gitCmd.AddOptionValues("--not", opts.Not) } + if opts.Since != "" { + gitCmd.AddOptionFormat("--since=%s", opts.Since) + } + if opts.Until != "" { + gitCmd.AddOptionFormat("--until=%s", opts.Until) + } + gitCmd.AddDashesAndList(opts.File) - length := objectFormat.FullLength() - commits := []*Commit{} - shaline := make([]byte, length+1) - for { - n, err := io.ReadFull(stdoutReader, shaline) - if err != nil || n < length { - if err == io.EOF { - err = nil + var commits []*Commit + stdoutReader, stdoutReaderClose := gitCmd.MakeStdoutPipe() + defer stdoutReaderClose() + err := gitCmd.WithDir(repo.Path). + WithPipelineFunc(func(context gitcmd.Context) error { + objectFormat, err := repo.GetObjectFormat() + if err != nil { + return err } - return commits, err - } - objectID, err := NewIDFromString(string(shaline[0:length])) - if err != nil { - return nil, err - } - commit, err := repo.getCommit(objectID) - if err != nil { - return nil, err - } - commits = append(commits, commit) - } + + length := objectFormat.FullLength() + shaline := make([]byte, length+1) + for { + n, err := io.ReadFull(stdoutReader, shaline) + if err != nil || n < length { + if err == io.EOF { + err = nil + } + return err + } + objectID, err := NewIDFromString(string(shaline[0:length])) + if err != nil { + return err + } + commit, err := repo.getCommit(objectID) + if err != nil { + return err + } + commits = append(commits, commit) + } + }). + RunWithStderr(repo.Ctx) + return commits, err } // FilesCountBetween return the number of files changed between two commits func (repo *Repository) FilesCountBetween(startCommitID, endCommitID string) (int, error) { - stdout, _, err := gitcmd.NewCommand("diff", "--name-only").AddDynamicArguments(startCommitID+"..."+endCommitID).RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) + stdout, _, err := gitcmd.NewCommand("diff", "--name-only"). + AddDynamicArguments(startCommitID + "..." + endCommitID). + WithDir(repo.Path). + RunStdString(repo.Ctx) if err != nil && strings.Contains(err.Error(), "no merge base") { // git >= 2.28 now returns an error if startCommitID and endCommitID have become unrelated. // previously it would return the results of git diff --name-only startCommitID endCommitID so let's try that... - stdout, _, err = gitcmd.NewCommand("diff", "--name-only").AddDynamicArguments(startCommitID, endCommitID).RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) + stdout, _, err = gitcmd.NewCommand("diff", "--name-only"). + AddDynamicArguments(startCommitID, endCommitID). + WithDir(repo.Path). + RunStdString(repo.Ctx) } if err != nil { return 0, err @@ -309,13 +303,22 @@ func (repo *Repository) CommitsBetween(last, before *Commit) ([]*Commit, error) var stdout []byte var err error if before == nil { - stdout, _, err = gitcmd.NewCommand("rev-list").AddDynamicArguments(last.ID.String()).RunStdBytes(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) + stdout, _, err = gitcmd.NewCommand("rev-list"). + AddDynamicArguments(last.ID.String()). + WithDir(repo.Path). + RunStdBytes(repo.Ctx) } else { - stdout, _, err = gitcmd.NewCommand("rev-list").AddDynamicArguments(before.ID.String()+".."+last.ID.String()).RunStdBytes(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) + stdout, _, err = gitcmd.NewCommand("rev-list"). + AddDynamicArguments(before.ID.String() + ".." + last.ID.String()). + WithDir(repo.Path). + RunStdBytes(repo.Ctx) if err != nil && strings.Contains(err.Error(), "no merge base") { // future versions of git >= 2.28 are likely to return an error if before and last have become unrelated. // previously it would return the results of git rev-list before last so let's try that... - stdout, _, err = gitcmd.NewCommand("rev-list").AddDynamicArguments(before.ID.String(), last.ID.String()).RunStdBytes(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) + stdout, _, err = gitcmd.NewCommand("rev-list"). + AddDynamicArguments(before.ID.String(), last.ID.String()). + WithDir(repo.Path). + RunStdBytes(repo.Ctx) } } if err != nil { @@ -332,19 +335,25 @@ func (repo *Repository) CommitsBetweenLimit(last, before *Commit, limit, skip in stdout, _, err = gitcmd.NewCommand("rev-list"). AddOptionValues("--max-count", strconv.Itoa(limit)). AddOptionValues("--skip", strconv.Itoa(skip)). - AddDynamicArguments(last.ID.String()).RunStdBytes(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) + AddDynamicArguments(last.ID.String()). + WithDir(repo.Path). + RunStdBytes(repo.Ctx) } else { stdout, _, err = gitcmd.NewCommand("rev-list"). AddOptionValues("--max-count", strconv.Itoa(limit)). AddOptionValues("--skip", strconv.Itoa(skip)). - AddDynamicArguments(before.ID.String()+".."+last.ID.String()).RunStdBytes(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) + AddDynamicArguments(before.ID.String() + ".." + last.ID.String()). + WithDir(repo.Path). + RunStdBytes(repo.Ctx) if err != nil && strings.Contains(err.Error(), "no merge base") { // future versions of git >= 2.28 are likely to return an error if before and last have become unrelated. // previously it would return the results of git rev-list --max-count n before last so let's try that... stdout, _, err = gitcmd.NewCommand("rev-list"). AddOptionValues("--max-count", strconv.Itoa(limit)). AddOptionValues("--skip", strconv.Itoa(skip)). - AddDynamicArguments(before.ID.String(), last.ID.String()).RunStdBytes(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) + AddDynamicArguments(before.ID.String(), last.ID.String()). + WithDir(repo.Path). + RunStdBytes(repo.Ctx) } } if err != nil { @@ -359,13 +368,25 @@ func (repo *Repository) CommitsBetweenNotBase(last, before *Commit, baseBranch s var stdout []byte var err error if before == nil { - stdout, _, err = gitcmd.NewCommand("rev-list").AddDynamicArguments(last.ID.String()).AddOptionValues("--not", baseBranch).RunStdBytes(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) + stdout, _, err = gitcmd.NewCommand("rev-list"). + AddDynamicArguments(last.ID.String()). + AddOptionValues("--not", baseBranch). + WithDir(repo.Path). + RunStdBytes(repo.Ctx) } else { - stdout, _, err = gitcmd.NewCommand("rev-list").AddDynamicArguments(before.ID.String()+".."+last.ID.String()).AddOptionValues("--not", baseBranch).RunStdBytes(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) + stdout, _, err = gitcmd.NewCommand("rev-list"). + AddDynamicArguments(before.ID.String()+".."+last.ID.String()). + AddOptionValues("--not", baseBranch). + WithDir(repo.Path). + RunStdBytes(repo.Ctx) if err != nil && strings.Contains(err.Error(), "no merge base") { // future versions of git >= 2.28 are likely to return an error if before and last have become unrelated. // previously it would return the results of git rev-list before last so let's try that... - stdout, _, err = gitcmd.NewCommand("rev-list").AddDynamicArguments(before.ID.String(), last.ID.String()).AddOptionValues("--not", baseBranch).RunStdBytes(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) + stdout, _, err = gitcmd.NewCommand("rev-list"). + AddDynamicArguments(before.ID.String(), last.ID.String()). + AddOptionValues("--not", baseBranch). + WithDir(repo.Path). + RunStdBytes(repo.Ctx) } } if err != nil { @@ -390,25 +411,6 @@ func (repo *Repository) CommitsBetweenIDs(last, before string) ([]*Commit, error return repo.CommitsBetween(lastCommit, beforeCommit) } -// CommitsCountBetween return numbers of commits between two commits -func (repo *Repository) CommitsCountBetween(start, end string) (int64, error) { - count, err := CommitsCount(repo.Ctx, CommitsCountOptions{ - RepoPath: repo.Path, - Revision: []string{start + ".." + end}, - }) - - if err != nil && strings.Contains(err.Error(), "no merge base") { - // future versions of git >= 2.28 are likely to return an error if before and last have become unrelated. - // previously it would return the results of git rev-list before last so let's try that... - return CommitsCount(repo.Ctx, CommitsCountOptions{ - RepoPath: repo.Path, - Revision: []string{start, end}, - }) - } - - return count, err -} - // commitsBefore the limit is depth, not total number of returned commits. func (repo *Repository) commitsBefore(id ObjectID, limit int) ([]*Commit, error) { cmd := gitcmd.NewCommand("log", prettyLogFormat) @@ -417,7 +419,7 @@ func (repo *Repository) commitsBefore(id ObjectID, limit int) ([]*Commit, error) } cmd.AddDynamicArguments(id.String()) - stdout, _, runErr := cmd.RunStdBytes(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) + stdout, _, runErr := cmd.WithDir(repo.Path).RunStdBytes(repo.Ctx) if runErr != nil { return nil, runErr } @@ -457,10 +459,9 @@ func (repo *Repository) getBranches(env []string, commitID string, limit int) ([ stdout, _, err := gitcmd.NewCommand("for-each-ref", "--format=%(refname:strip=2)"). AddOptionFormat("--count=%d", limit). AddOptionValues("--contains", commitID, BranchPrefix). - RunStdString(repo.Ctx, &gitcmd.RunOpts{ - Dir: repo.Path, - Env: env, - }) + WithDir(repo.Path). + WithEnv(env). + RunStdString(repo.Ctx) if err != nil { return nil, err } @@ -469,10 +470,11 @@ func (repo *Repository) getBranches(env []string, commitID string, limit int) ([ return branches, nil } - stdout, _, err := gitcmd.NewCommand("branch").AddOptionValues("--contains", commitID).RunStdString(repo.Ctx, &gitcmd.RunOpts{ - Dir: repo.Path, - Env: env, - }) + stdout, _, err := gitcmd.NewCommand("branch"). + AddOptionValues("--contains", commitID). + WithDir(repo.Path). + WithEnv(env). + RunStdString(repo.Ctx) if err != nil { return nil, err } @@ -511,39 +513,24 @@ func (repo *Repository) GetCommitsFromIDs(commitIDs []string) []*Commit { // IsCommitInBranch check if the commit is on the branch func (repo *Repository) IsCommitInBranch(commitID, branch string) (r bool, err error) { - stdout, _, err := gitcmd.NewCommand("branch", "--contains").AddDynamicArguments(commitID, branch).RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) + stdout, _, err := gitcmd.NewCommand("branch", "--contains"). + AddDynamicArguments(commitID, branch). + WithDir(repo.Path). + RunStdString(repo.Ctx) if err != nil { return false, err } return len(stdout) > 0, err } -func (repo *Repository) AddLastCommitCache(cacheKey, fullName, sha string) error { - if repo.LastCommitCache == nil { - commitsCount, err := cache.GetInt64(cacheKey, func() (int64, error) { - commit, err := repo.GetCommit(sha) - if err != nil { - return 0, err - } - return commit.CommitsCount() - }) - if err != nil { - return err - } - repo.LastCommitCache = NewLastCommitCache(commitsCount, fullName, repo, cache.GetCache()) - } - return nil -} - // GetCommitBranchStart returns the commit where the branch diverged func (repo *Repository) GetCommitBranchStart(env []string, branch, endCommitID string) (string, error) { cmd := gitcmd.NewCommand("log", prettyLogFormat) cmd.AddDynamicArguments(endCommitID) - stdout, _, runErr := cmd.RunStdBytes(repo.Ctx, &gitcmd.RunOpts{ - Dir: repo.Path, - Env: env, - }) + stdout, _, runErr := cmd.WithDir(repo.Path). + WithEnv(env). + RunStdBytes(repo.Ctx) if runErr != nil { return "", runErr } diff --git a/modules/git/repo_commit_gogit.go b/modules/git/repo_commit_gogit.go index fc653714adb..550d1537220 100644 --- a/modules/git/repo_commit_gogit.go +++ b/modules/git/repo_commit_gogit.go @@ -51,7 +51,10 @@ func (repo *Repository) ConvertToGitID(commitID string) (ObjectID, error) { } } - actualCommitID, _, err := gitcmd.NewCommand("rev-parse", "--verify").AddDynamicArguments(commitID).RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) + actualCommitID, _, err := gitcmd.NewCommand("rev-parse", "--verify"). + AddDynamicArguments(commitID). + WithDir(repo.Path). + RunStdString(repo.Ctx) actualCommitID = strings.TrimSpace(actualCommitID) if err != nil { if strings.Contains(err.Error(), "unknown revision or path") || @@ -64,16 +67,6 @@ func (repo *Repository) ConvertToGitID(commitID string) (ObjectID, error) { return NewIDFromString(actualCommitID) } -// IsCommitExist returns true if given commit exists in current repository. -func (repo *Repository) IsCommitExist(name string) bool { - hash, err := repo.ConvertToGitID(name) - if err != nil { - return false - } - _, err = repo.gogitRepo.CommitObject(plumbing.Hash(hash.RawValue())) - return err == nil -} - func (repo *Repository) getCommit(id ObjectID) (*Commit, error) { var tagObject *object.Tag @@ -104,7 +97,7 @@ func (repo *Repository) getCommit(id ObjectID) (*Commit, error) { } commit.Tree.ID = ParseGogitHash(tree.Hash) - commit.Tree.gogitTree = tree + commit.Tree.resolvedGogitTreeObject = tree return commit, nil } diff --git a/modules/git/repo_commit_nogogit.go b/modules/git/repo_commit_nogogit.go index d2c66a541b7..2ddb5275020 100644 --- a/modules/git/repo_commit_nogogit.go +++ b/modules/git/repo_commit_nogogit.go @@ -6,7 +6,6 @@ package git import ( - "bufio" "errors" "io" "strings" @@ -17,7 +16,10 @@ import ( // ResolveReference resolves a name to a reference func (repo *Repository) ResolveReference(name string) (string, error) { - stdout, _, err := gitcmd.NewCommand("show-ref", "--hash").AddDynamicArguments(name).RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) + stdout, _, err := gitcmd.NewCommand("show-ref", "--hash"). + AddDynamicArguments(name). + WithDir(repo.Path). + RunStdString(repo.Ctx) if err != nil { if strings.Contains(err.Error(), "not a valid ref") { return "", ErrNotExist{name, ""} @@ -34,47 +36,31 @@ func (repo *Repository) ResolveReference(name string) (string, error) { // GetRefCommitID returns the last commit ID string of given reference (branch or tag). func (repo *Repository) GetRefCommitID(name string) (string, error) { - wr, rd, cancel, err := repo.CatFileBatchCheck(repo.Ctx) + batch, cancel, err := repo.CatFileBatch(repo.Ctx) if err != nil { return "", err } defer cancel() - _, err = wr.Write([]byte(name + "\n")) - if err != nil { - return "", err - } - shaBs, _, _, err := ReadBatchLine(rd) + info, err := batch.QueryInfo(name) if IsErrNotExist(err) { return "", ErrNotExist{name, ""} + } else if err != nil { + return "", err } - - return string(shaBs), nil -} - -// IsCommitExist returns true if given commit exists in current repository. -func (repo *Repository) IsCommitExist(name string) bool { - if err := ensureValidGitRepository(repo.Ctx, repo.Path); err != nil { - log.Error("IsCommitExist: %v", err) - return false - } - _, _, err := gitcmd.NewCommand("cat-file", "-e").AddDynamicArguments(name).RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) - return err == nil + return info.ID, nil } func (repo *Repository) getCommit(id ObjectID) (*Commit, error) { - wr, rd, cancel, err := repo.CatFileBatch(repo.Ctx) + batch, cancel, err := repo.CatFileBatch(repo.Ctx) if err != nil { return nil, err } defer cancel() - - _, _ = wr.Write([]byte(id.String() + "\n")) - - return repo.getCommitFromBatchReader(wr, rd, id) + return repo.getCommitWithBatch(batch, id) } -func (repo *Repository) getCommitFromBatchReader(wr WriteCloserError, rd *bufio.Reader, id ObjectID) (*Commit, error) { - _, typ, size, err := ReadBatchLine(rd) +func (repo *Repository) getCommitWithBatch(batch CatFileBatch, id ObjectID) (*Commit, error) { + info, rd, err := batch.QueryContent(id.String()) if err != nil { if errors.Is(err, io.EOF) || IsErrNotExist(err) { return nil, ErrNotExist{ID: id.String()} @@ -82,13 +68,13 @@ func (repo *Repository) getCommitFromBatchReader(wr WriteCloserError, rd *bufio. return nil, err } - switch typ { + switch info.Type { case "missing": return nil, ErrNotExist{ID: id.String()} case "tag": // then we need to parse the tag // and load the commit - data, err := io.ReadAll(io.LimitReader(rd, size)) + data, err := io.ReadAll(io.LimitReader(rd, info.Size)) if err != nil { return nil, err } @@ -100,19 +86,9 @@ func (repo *Repository) getCommitFromBatchReader(wr WriteCloserError, rd *bufio. if err != nil { return nil, err } - - if _, err := wr.Write([]byte(tag.Object.String() + "\n")); err != nil { - return nil, err - } - - commit, err := repo.getCommitFromBatchReader(wr, rd, tag.Object) - if err != nil { - return nil, err - } - - return commit, nil + return repo.getCommitWithBatch(batch, tag.Object) case "commit": - commit, err := CommitFromReader(repo, id, io.LimitReader(rd, size)) + commit, err := CommitFromReader(repo, id, io.LimitReader(rd, info.Size)) if err != nil { return nil, err } @@ -123,8 +99,8 @@ func (repo *Repository) getCommitFromBatchReader(wr WriteCloserError, rd *bufio. return commit, nil default: - log.Debug("Unknown typ: %s", typ) - if err := DiscardFull(rd, size+1); err != nil { + log.Debug("Unknown cat-file object type: %s", info.Type) + if err := DiscardFull(rd, info.Size+1); err != nil { return nil, err } return nil, ErrNotExist{ @@ -146,16 +122,12 @@ func (repo *Repository) ConvertToGitID(commitID string) (ObjectID, error) { } } - wr, rd, cancel, err := repo.CatFileBatchCheck(repo.Ctx) + batch, cancel, err := repo.CatFileBatch(repo.Ctx) if err != nil { return nil, err } defer cancel() - _, err = wr.Write([]byte(commitID + "\n")) - if err != nil { - return nil, err - } - sha, _, _, err := ReadBatchLine(rd) + info, err := batch.QueryInfo(commitID) if err != nil { if IsErrNotExist(err) { return nil, ErrNotExist{commitID, ""} @@ -163,5 +135,5 @@ func (repo *Repository) ConvertToGitID(commitID string) (ObjectID, error) { return nil, err } - return MustIDFromString(string(sha)), nil + return MustIDFromString(info.ID), nil } diff --git a/modules/git/repo_commitgraph.go b/modules/git/repo_commitgraph.go index 331c799b334..3dac74304c1 100644 --- a/modules/git/repo_commitgraph.go +++ b/modules/git/repo_commitgraph.go @@ -14,7 +14,7 @@ import ( // this requires git v2.18 to be installed func WriteCommitGraph(ctx context.Context, repoPath string) error { if DefaultFeatures().CheckVersionAtLeast("2.18") { - if _, _, err := gitcmd.NewCommand("commit-graph", "write").RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath}); err != nil { + if _, _, err := gitcmd.NewCommand("commit-graph", "write").WithDir(repoPath).RunStdString(ctx); err != nil { return fmt.Errorf("unable to write commit-graph for '%s' : %w", repoPath, err) } } diff --git a/modules/git/repo_compare.go b/modules/git/repo_compare.go index 69a7314d5cf..aa25e2ec208 100644 --- a/modules/git/repo_compare.go +++ b/modules/git/repo_compare.go @@ -7,38 +7,17 @@ package git import ( "bufio" "bytes" - "context" "errors" "fmt" "io" "os" "path/filepath" "regexp" - "strconv" "strings" "code.gitea.io/gitea/modules/git/gitcmd" ) -// GetMergeBase checks and returns merge base of two branches and the reference used as base. -func (repo *Repository) GetMergeBase(tmpRemote, base, head string) (string, string, error) { - if tmpRemote == "" { - tmpRemote = "origin" - } - - if tmpRemote != "origin" { - tmpBaseName := RemotePrefix + tmpRemote + "/tmp_" + base - // Fetch commit into a temporary branch in order to be able to handle commits and tags - _, _, err := gitcmd.NewCommand("fetch", "--no-tags").AddDynamicArguments(tmpRemote).AddDashesAndList(base+":"+tmpBaseName).RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) - if err == nil { - base = tmpBaseName - } - } - - stdout, _, err := gitcmd.NewCommand("merge-base").AddDashesAndList(base, head).RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) - return strings.TrimSpace(stdout), base, err -} - type lineCountWriter struct { numLines int } @@ -55,7 +34,6 @@ func (l *lineCountWriter) Write(p []byte) (n int, err error) { func (repo *Repository) GetDiffNumChangedFiles(base, head string, directComparison bool) (int, error) { // Now there is git diff --shortstat but this appears to be slower than simply iterating with --nameonly w := &lineCountWriter{} - stderr := new(bytes.Buffer) separator := "..." if directComparison { @@ -63,109 +41,55 @@ func (repo *Repository) GetDiffNumChangedFiles(base, head string, directComparis } // avoid: ambiguous argument 'refs/a...refs/b': unknown revision or path not in the working tree. Use '--': 'git [...] -- [...]' - if err := gitcmd.NewCommand("diff", "-z", "--name-only").AddDynamicArguments(base+separator+head).AddArguments("--"). - Run(repo.Ctx, &gitcmd.RunOpts{ - Dir: repo.Path, - Stdout: w, - Stderr: stderr, - }); err != nil { - if strings.Contains(stderr.String(), "no merge base") { + if err := gitcmd.NewCommand("diff", "-z", "--name-only"). + AddDynamicArguments(base + separator + head). + AddArguments("--"). + WithDir(repo.Path). + WithStdoutCopy(w). + RunWithStderr(repo.Ctx); err != nil { + if strings.Contains(err.Stderr(), "no merge base") { // git >= 2.28 now returns an error if base and head have become unrelated. // previously it would return the results of git diff -z --name-only base head so let's try that... w = &lineCountWriter{} - stderr.Reset() - if err = gitcmd.NewCommand("diff", "-z", "--name-only").AddDynamicArguments(base, head).AddArguments("--").Run(repo.Ctx, &gitcmd.RunOpts{ - Dir: repo.Path, - Stdout: w, - Stderr: stderr, - }); err == nil { + if err = gitcmd.NewCommand("diff", "-z", "--name-only"). + AddDynamicArguments(base, head). + AddArguments("--"). + WithDir(repo.Path). + WithStdoutCopy(w). + RunWithStderr(repo.Ctx); err == nil { return w.numLines, nil } } - return 0, fmt.Errorf("%w: Stderr: %s", err, stderr) + return 0, err } return w.numLines, nil } -// GetDiffShortStatByCmdArgs counts number of changed files, number of additions and deletions -// TODO: it can be merged with another "GetDiffShortStat" in the future -func GetDiffShortStatByCmdArgs(ctx context.Context, repoPath string, trustedArgs gitcmd.TrustedCmdArgs, dynamicArgs ...string) (numFiles, totalAdditions, totalDeletions int, err error) { - // Now if we call: - // $ git diff --shortstat 1ebb35b98889ff77299f24d82da426b434b0cca0...788b8b1440462d477f45b0088875 - // we get: - // " 9902 files changed, 2034198 insertions(+), 298800 deletions(-)\n" - cmd := gitcmd.NewCommand("diff", "--shortstat").AddArguments(trustedArgs...).AddDynamicArguments(dynamicArgs...) - stdout, _, err := cmd.RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath}) - if err != nil { - return 0, 0, 0, err - } - - return parseDiffStat(stdout) -} - -var shortStatFormat = regexp.MustCompile( - `\s*(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?`) - var patchCommits = regexp.MustCompile(`^From\s(\w+)\s`) -func parseDiffStat(stdout string) (numFiles, totalAdditions, totalDeletions int, err error) { - if len(stdout) == 0 || stdout == "\n" { - return 0, 0, 0, nil - } - groups := shortStatFormat.FindStringSubmatch(stdout) - if len(groups) != 4 { - return 0, 0, 0, fmt.Errorf("unable to parse shortstat: %s groups: %s", stdout, groups) - } - - numFiles, err = strconv.Atoi(groups[1]) - if err != nil { - return 0, 0, 0, fmt.Errorf("unable to parse shortstat: %s. Error parsing NumFiles %w", stdout, err) - } - - if len(groups[2]) != 0 { - totalAdditions, err = strconv.Atoi(groups[2]) - if err != nil { - return 0, 0, 0, fmt.Errorf("unable to parse shortstat: %s. Error parsing NumAdditions %w", stdout, err) - } - } - - if len(groups[3]) != 0 { - totalDeletions, err = strconv.Atoi(groups[3]) - if err != nil { - return 0, 0, 0, fmt.Errorf("unable to parse shortstat: %s. Error parsing NumDeletions %w", stdout, err) - } - } - return numFiles, totalAdditions, totalDeletions, err -} - // GetDiff generates and returns patch data between given revisions, optimized for human readability func (repo *Repository) GetDiff(compareArg string, w io.Writer) error { - stderr := new(bytes.Buffer) return gitcmd.NewCommand("diff", "-p").AddDynamicArguments(compareArg). - Run(repo.Ctx, &gitcmd.RunOpts{ - Dir: repo.Path, - Stdout: w, - Stderr: stderr, - }) + WithDir(repo.Path). + WithStdoutCopy(w). + Run(repo.Ctx) } // GetDiffBinary generates and returns patch data between given revisions, including binary diffs. func (repo *Repository) GetDiffBinary(compareArg string, w io.Writer) error { - return gitcmd.NewCommand("diff", "-p", "--binary", "--histogram").AddDynamicArguments(compareArg).Run(repo.Ctx, &gitcmd.RunOpts{ - Dir: repo.Path, - Stdout: w, - }) + return gitcmd.NewCommand("diff", "-p", "--binary", "--histogram"). + AddDynamicArguments(compareArg). + WithDir(repo.Path). + WithStdoutCopy(w). + Run(repo.Ctx) } // GetPatch generates and returns format-patch data between given revisions, able to be used with `git apply` func (repo *Repository) GetPatch(compareArg string, w io.Writer) error { - stderr := new(bytes.Buffer) return gitcmd.NewCommand("format-patch", "--binary", "--stdout").AddDynamicArguments(compareArg). - Run(repo.Ctx, &gitcmd.RunOpts{ - Dir: repo.Path, - Stdout: w, - Stderr: stderr, - }) + WithDir(repo.Path). + WithStdoutCopy(w). + Run(repo.Ctx) } // GetFilesChangedBetween returns a list of all files that have been changed between the given commits @@ -182,7 +106,7 @@ func (repo *Repository) GetFilesChangedBetween(base, head string) ([]string, err } else { cmd.AddDynamicArguments(base, head) } - stdout, _, err := cmd.RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) + stdout, _, err := cmd.WithDir(repo.Path).RunStdString(repo.Ctx) if err != nil { return nil, err } diff --git a/modules/git/repo_compare_test.go b/modules/git/repo_compare_test.go index 47fd2ca102f..bf16b7cfceb 100644 --- a/modules/git/repo_compare_test.go +++ b/modules/git/repo_compare_test.go @@ -103,7 +103,8 @@ func TestReadWritePullHead(t *testing.T) { newCommit := "feaf4ba6bc635fec442f46ddd4512416ec43c2c2" _, _, err = gitcmd.NewCommand("update-ref"). AddDynamicArguments(PullPrefix+"1/head", newCommit). - RunStdString(t.Context(), &gitcmd.RunOpts{Dir: repo.Path}) + WithDir(repo.Path). + RunStdString(t.Context()) if err != nil { assert.NoError(t, err) return @@ -121,8 +122,9 @@ func TestReadWritePullHead(t *testing.T) { // Remove file after the test _, _, err = gitcmd.NewCommand("update-ref", "--no-deref", "-d"). - AddDynamicArguments(PullPrefix+"1/head"). - RunStdString(t.Context(), &gitcmd.RunOpts{Dir: repo.Path}) + AddDynamicArguments(PullPrefix + "1/head"). + WithDir(repo.Path). + RunStdString(t.Context()) assert.NoError(t, err) } diff --git a/modules/git/repo_gpg.go b/modules/git/repo_gpg.go deleted file mode 100644 index a999d2dbc60..00000000000 --- a/modules/git/repo_gpg.go +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright 2015 The Gogs Authors. All rights reserved. -// Copyright 2017 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package git - -import ( - "fmt" - "os" - "strings" - - "code.gitea.io/gitea/modules/git/gitcmd" - "code.gitea.io/gitea/modules/process" -) - -// LoadPublicKeyContent will load the key from gpg -func (gpgSettings *GPGSettings) LoadPublicKeyContent() error { - if gpgSettings.Format == SigningKeyFormatSSH { - content, err := os.ReadFile(gpgSettings.KeyID) - if err != nil { - return fmt.Errorf("unable to read SSH public key file: %s, %w", gpgSettings.KeyID, err) - } - gpgSettings.PublicKeyContent = string(content) - return nil - } - content, stderr, err := process.GetManager().Exec( - "gpg -a --export", - "gpg", "-a", "--export", gpgSettings.KeyID) - if err != nil { - return fmt.Errorf("unable to get default signing key: %s, %s, %w", gpgSettings.KeyID, stderr, err) - } - gpgSettings.PublicKeyContent = content - return nil -} - -// GetDefaultPublicGPGKey will return and cache the default public GPG settings for this repository -func (repo *Repository) GetDefaultPublicGPGKey(forceUpdate bool) (*GPGSettings, error) { - if repo.gpgSettings != nil && !forceUpdate { - return repo.gpgSettings, nil - } - - gpgSettings := &GPGSettings{ - Sign: true, - } - - value, _, _ := gitcmd.NewCommand("config", "--get", "commit.gpgsign").RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) - sign, valid := ParseBool(strings.TrimSpace(value)) - if !sign || !valid { - gpgSettings.Sign = false - repo.gpgSettings = gpgSettings - return gpgSettings, nil - } - - signingKey, _, _ := gitcmd.NewCommand("config", "--get", "user.signingkey").RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) - gpgSettings.KeyID = strings.TrimSpace(signingKey) - - format, _, _ := gitcmd.NewCommand("config", "--default", SigningKeyFormatOpenPGP, "--get", "gpg.format").RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) - gpgSettings.Format = strings.TrimSpace(format) - - defaultEmail, _, _ := gitcmd.NewCommand("config", "--get", "user.email").RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) - gpgSettings.Email = strings.TrimSpace(defaultEmail) - - defaultName, _, _ := gitcmd.NewCommand("config", "--get", "user.name").RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) - gpgSettings.Name = strings.TrimSpace(defaultName) - - if err := gpgSettings.LoadPublicKeyContent(); err != nil { - return nil, err - } - repo.gpgSettings = gpgSettings - return repo.gpgSettings, nil -} diff --git a/modules/git/repo_index.go b/modules/git/repo_index.go index e7b3792d95a..1d040d5e0a4 100644 --- a/modules/git/repo_index.go +++ b/modules/git/repo_index.go @@ -22,7 +22,7 @@ func (repo *Repository) ReadTreeToIndex(treeish string, indexFilename ...string) } if len(treeish) != objectFormat.FullLength() { - res, _, err := gitcmd.NewCommand("rev-parse", "--verify").AddDynamicArguments(treeish).RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) + res, _, err := gitcmd.NewCommand("rev-parse", "--verify").AddDynamicArguments(treeish).WithDir(repo.Path).RunStdString(repo.Ctx) if err != nil { return err } @@ -42,7 +42,7 @@ func (repo *Repository) readTreeToIndex(id ObjectID, indexFilename ...string) er if len(indexFilename) > 0 { env = append(os.Environ(), "GIT_INDEX_FILE="+indexFilename[0]) } - _, _, err := gitcmd.NewCommand("read-tree").AddDynamicArguments(id.String()).RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path, Env: env}) + _, _, err := gitcmd.NewCommand("read-tree").AddDynamicArguments(id.String()).WithDir(repo.Path).WithEnv(env).RunStdString(repo.Ctx) if err != nil { return err } @@ -75,14 +75,14 @@ func (repo *Repository) ReadTreeToTemporaryIndex(treeish string) (tmpIndexFilena // EmptyIndex empties the index func (repo *Repository) EmptyIndex() error { - _, _, err := gitcmd.NewCommand("read-tree", "--empty").RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) + _, _, err := gitcmd.NewCommand("read-tree", "--empty").WithDir(repo.Path).RunStdString(repo.Ctx) return err } // LsFiles checks if the given filenames are in the index func (repo *Repository) LsFiles(filenames ...string) ([]string, error) { cmd := gitcmd.NewCommand("ls-files", "-z").AddDashesAndList(filenames...) - res, _, err := cmd.RunStdBytes(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) + res, _, err := cmd.WithDir(repo.Path).RunStdBytes(repo.Ctx) if err != nil { return nil, err } @@ -101,21 +101,17 @@ func (repo *Repository) RemoveFilesFromIndex(filenames ...string) error { return err } cmd := gitcmd.NewCommand("update-index", "--remove", "-z", "--index-info") - stdout := new(bytes.Buffer) - stderr := new(bytes.Buffer) - buffer := new(bytes.Buffer) + input := new(bytes.Buffer) for _, file := range filenames { if file != "" { // using format: mode SP type SP sha1 TAB path - buffer.WriteString("0 blob " + objectFormat.EmptyObjectID().String() + "\t" + file + "\000") + input.WriteString("0 blob " + objectFormat.EmptyObjectID().String() + "\t" + file + "\000") } } - return cmd.Run(repo.Ctx, &gitcmd.RunOpts{ - Dir: repo.Path, - Stdin: bytes.NewReader(buffer.Bytes()), - Stdout: stdout, - Stderr: stderr, - }) + return cmd. + WithDir(repo.Path). + WithStdinBytes(input.Bytes()). + RunWithStderr(repo.Ctx) } type IndexObjectInfo struct { @@ -127,19 +123,15 @@ type IndexObjectInfo struct { // AddObjectsToIndex adds the provided object hashes to the index at the provided filenames func (repo *Repository) AddObjectsToIndex(objects ...IndexObjectInfo) error { cmd := gitcmd.NewCommand("update-index", "--add", "--replace", "-z", "--index-info") - stdout := new(bytes.Buffer) - stderr := new(bytes.Buffer) - buffer := new(bytes.Buffer) + input := new(bytes.Buffer) for _, object := range objects { // using format: mode SP type SP sha1 TAB path - buffer.WriteString(object.Mode + " blob " + object.Object.String() + "\t" + object.Filename + "\000") + input.WriteString(object.Mode + " blob " + object.Object.String() + "\t" + object.Filename + "\000") } - return cmd.Run(repo.Ctx, &gitcmd.RunOpts{ - Dir: repo.Path, - Stdin: bytes.NewReader(buffer.Bytes()), - Stdout: stdout, - Stderr: stderr, - }) + return cmd. + WithDir(repo.Path). + WithStdinBytes(input.Bytes()). + RunWithStderr(repo.Ctx) } // AddObjectToIndex adds the provided object hash to the index at the provided filename @@ -149,7 +141,7 @@ func (repo *Repository) AddObjectToIndex(mode string, object ObjectID, filename // WriteTree writes the current index as a tree to the object db and returns its hash func (repo *Repository) WriteTree() (*Tree, error) { - stdout, _, runErr := gitcmd.NewCommand("write-tree").RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) + stdout, _, runErr := gitcmd.NewCommand("write-tree").WithDir(repo.Path).RunStdString(repo.Ctx) if runErr != nil { return nil, runErr } diff --git a/modules/git/repo_object.go b/modules/git/repo_object.go index e8f6510c237..38e16b4646f 100644 --- a/modules/git/repo_object.go +++ b/modules/git/repo_object.go @@ -5,8 +5,6 @@ package git import ( - "bytes" - "io" "strings" "code.gitea.io/gitea/modules/git/gitcmd" @@ -33,18 +31,12 @@ func (o ObjectType) Bytes() []byte { return []byte(o) } -type EmptyReader struct{} - -func (EmptyReader) Read(p []byte) (int, error) { - return 0, io.EOF -} - func (repo *Repository) GetObjectFormat() (ObjectFormat, error) { if repo != nil && repo.objectFormat != nil { return repo.objectFormat, nil } - str, err := repo.hashObject(EmptyReader{}, false) + str, err := repo.hashObjectBytes(nil, false) if err != nil { return nil, err } @@ -58,32 +50,28 @@ func (repo *Repository) GetObjectFormat() (ObjectFormat, error) { return repo.objectFormat, nil } -// HashObject takes a reader and returns hash for that reader -func (repo *Repository) HashObject(reader io.Reader) (ObjectID, error) { - idStr, err := repo.hashObject(reader, true) +// HashObjectBytes returns hash for the content +func (repo *Repository) HashObjectBytes(buf []byte) (ObjectID, error) { + idStr, err := repo.hashObjectBytes(buf, true) if err != nil { return nil, err } return NewIDFromString(idStr) } -func (repo *Repository) hashObject(reader io.Reader, save bool) (string, error) { +func (repo *Repository) hashObjectBytes(buf []byte, save bool) (string, error) { var cmd *gitcmd.Command if save { cmd = gitcmd.NewCommand("hash-object", "-w", "--stdin") } else { cmd = gitcmd.NewCommand("hash-object", "--stdin") } - stdout := new(bytes.Buffer) - stderr := new(bytes.Buffer) - err := cmd.Run(repo.Ctx, &gitcmd.RunOpts{ - Dir: repo.Path, - Stdin: reader, - Stdout: stdout, - Stderr: stderr, - }) + stdout, _, err := cmd. + WithDir(repo.Path). + WithStdinBytes(buf). + RunStdString(repo.Ctx) if err != nil { return "", err } - return strings.TrimSpace(stdout.String()), nil + return strings.TrimSpace(stdout), nil } diff --git a/modules/git/repo_ref.go b/modules/git/repo_ref.go index 577e17c45df..8859a93a578 100644 --- a/modules/git/repo_ref.go +++ b/modules/git/repo_ref.go @@ -28,7 +28,8 @@ func (repo *Repository) ListOccurrences(ctx context.Context, refType, commitSHA default: return nil, util.NewInvalidArgumentErrorf(`can only use "branch" or "tag" for refType, but got %q`, refType) } - stdout, _, err := cmd.AddArguments("--no-color", "--sort=-creatordate", "--contains").AddDynamicArguments(commitSHA).RunStdString(ctx, &gitcmd.RunOpts{Dir: repo.Path}) + stdout, _, err := cmd.AddArguments("--no-color", "--sort=-creatordate", "--contains"). + AddDynamicArguments(commitSHA).WithDir(repo.Path).RunStdString(ctx) if err != nil { return nil, err } diff --git a/modules/git/repo_ref_nogogit.go b/modules/git/repo_ref_nogogit.go index 784efecc654..c58992fa9d1 100644 --- a/modules/git/repo_ref_nogogit.go +++ b/modules/git/repo_ref_nogogit.go @@ -15,75 +15,61 @@ import ( // GetRefsFiltered returns all references of the repository that matches patterm exactly or starting with. func (repo *Repository) GetRefsFiltered(pattern string) ([]*Reference, error) { - stdoutReader, stdoutWriter := io.Pipe() - defer func() { - _ = stdoutReader.Close() - _ = stdoutWriter.Close() - }() - - go func() { - stderrBuilder := &strings.Builder{} - err := gitcmd.NewCommand("for-each-ref").Run(repo.Ctx, &gitcmd.RunOpts{ - Dir: repo.Path, - Stdout: stdoutWriter, - Stderr: stderrBuilder, - }) - if err != nil { - _ = stdoutWriter.CloseWithError(gitcmd.ConcatenateError(err, stderrBuilder.String())) - } else { - _ = stdoutWriter.Close() - } - }() - refs := make([]*Reference, 0) - bufReader := bufio.NewReader(stdoutReader) - for { - // The output of for-each-ref is simply a list: - // SP TAB LF - sha, err := bufReader.ReadString(' ') - if err == io.EOF { - break - } - if err != nil { - return nil, err - } - sha = sha[:len(sha)-1] + cmd := gitcmd.NewCommand("for-each-ref") + stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe() + defer stdoutReaderClose() + err := cmd.WithDir(repo.Path). + WithPipelineFunc(func(context gitcmd.Context) error { + bufReader := bufio.NewReader(stdoutReader) + for { + // The output of for-each-ref is simply a list: + // SP TAB LF + sha, err := bufReader.ReadString(' ') + if err == io.EOF { + break + } + if err != nil { + return err + } + sha = sha[:len(sha)-1] - typ, err := bufReader.ReadString('\t') - if err == io.EOF { - // This should not happen, but we'll tolerate it - break - } - if err != nil { - return nil, err - } - typ = typ[:len(typ)-1] + typ, err := bufReader.ReadString('\t') + if err == io.EOF { + // This should not happen, but we'll tolerate it + break + } + if err != nil { + return err + } + typ = typ[:len(typ)-1] - refName, err := bufReader.ReadString('\n') - if err == io.EOF { - // This should not happen, but we'll tolerate it - break - } - if err != nil { - return nil, err - } - refName = refName[:len(refName)-1] + refName, err := bufReader.ReadString('\n') + if err == io.EOF { + // This should not happen, but we'll tolerate it + break + } + if err != nil { + return err + } + refName = refName[:len(refName)-1] - // refName cannot be HEAD but can be remotes or stash - if strings.HasPrefix(refName, RemotePrefix) || refName == "/refs/stash" { - continue - } + // refName cannot be HEAD but can be remotes or stash + if strings.HasPrefix(refName, RemotePrefix) || refName == "/refs/stash" { + continue + } - if pattern == "" || strings.HasPrefix(refName, pattern) { - r := &Reference{ - Name: refName, - Object: MustIDFromString(sha), - Type: typ, - repo: repo, + if pattern == "" || strings.HasPrefix(refName, pattern) { + r := &Reference{ + Name: refName, + Object: MustIDFromString(sha), + Type: typ, + repo: repo, + } + refs = append(refs, r) + } } - refs = append(refs, r) - } - } - - return refs, nil + return nil + }).RunWithStderr(repo.Ctx) + return refs, err } diff --git a/modules/git/repo_stats.go b/modules/git/repo_stats.go index 22082325efb..1dd77f05d4e 100644 --- a/modules/git/repo_stats.go +++ b/modules/git/repo_stats.go @@ -5,9 +5,7 @@ package git import ( "bufio" - "context" "fmt" - "os" "sort" "strconv" "strings" @@ -43,7 +41,8 @@ func (repo *Repository) GetCodeActivityStats(fromTime time.Time, branch string) stdout, _, runErr := gitcmd.NewCommand("rev-list", "--count", "--no-merges", "--branches=*", "--date=iso"). AddOptionFormat("--since=%s", since). - RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) + WithDir(repo.Path). + RunStdString(repo.Ctx) if runErr != nil { return nil, runErr } @@ -54,15 +53,6 @@ func (repo *Repository) GetCodeActivityStats(fromTime time.Time, branch string) } stats.CommitCountInAllBranches = c - stdoutReader, stdoutWriter, err := os.Pipe() - if err != nil { - return nil, err - } - defer func() { - _ = stdoutReader.Close() - _ = stdoutWriter.Close() - }() - gitCmd := gitcmd.NewCommand("log", "--numstat", "--no-merges", "--pretty=format:---%n%h%n%aN%n%aE%n", "--date=iso"). AddOptionFormat("--since=%s", since) if len(branch) == 0 { @@ -71,14 +61,11 @@ func (repo *Repository) GetCodeActivityStats(fromTime time.Time, branch string) gitCmd.AddArguments("--first-parent").AddDynamicArguments(branch) } - stderr := new(strings.Builder) - err = gitCmd.Run(repo.Ctx, &gitcmd.RunOpts{ - Env: []string{}, - Dir: repo.Path, - Stdout: stdoutWriter, - Stderr: stderr, - PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error { - _ = stdoutWriter.Close() + stdoutReader, stdoutReaderClose := gitCmd.MakeStdoutPipe() + defer stdoutReaderClose() + err = gitCmd. + WithDir(repo.Path). + WithPipelineFunc(func(ctx gitcmd.Context) error { scanner := bufio.NewScanner(stdoutReader) scanner.Split(bufio.ScanLines) stats.CommitCount = 0 @@ -129,7 +116,6 @@ func (repo *Repository) GetCodeActivityStats(fromTime time.Time, branch string) } } if err = scanner.Err(); err != nil { - _ = stdoutReader.Close() return fmt.Errorf("GetCodeActivityStats scan: %w", err) } a := make([]*CodeActivityAuthor, 0, len(authors)) @@ -143,12 +129,11 @@ func (repo *Repository) GetCodeActivityStats(fromTime time.Time, branch string) stats.AuthorCount = int64(len(authors)) stats.ChangedFiles = int64(len(files)) stats.Authors = a - _ = stdoutReader.Close() return nil - }, - }) + }). + RunWithStderr(repo.Ctx) if err != nil { - return nil, fmt.Errorf("Failed to get GetCodeActivityStats for repository.\nError: %w\nStderr: %s", err, stderr) + return nil, fmt.Errorf("GetCodeActivityStats: %w", err) } return stats, nil diff --git a/modules/git/repo_tag.go b/modules/git/repo_tag.go index 0cb09324590..2599236ae03 100644 --- a/modules/git/repo_tag.go +++ b/modules/git/repo_tag.go @@ -6,7 +6,6 @@ package git import ( "fmt" - "io" "strings" "code.gitea.io/gitea/modules/git/foreachref" @@ -19,13 +18,17 @@ const TagPrefix = "refs/tags/" // CreateTag create one tag in the repository func (repo *Repository) CreateTag(name, revision string) error { - _, _, err := gitcmd.NewCommand("tag").AddDashesAndList(name, revision).RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) + _, _, err := gitcmd.NewCommand("tag").AddDashesAndList(name, revision).WithDir(repo.Path).RunStdString(repo.Ctx) return err } // CreateAnnotatedTag create one annotated tag in the repository func (repo *Repository) CreateAnnotatedTag(name, message, revision string) error { - _, _, err := gitcmd.NewCommand("tag", "-a", "-m").AddDynamicArguments(message).AddDashesAndList(name, revision).RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) + _, _, err := gitcmd.NewCommand("tag", "-a", "-m"). + AddDynamicArguments(message). + AddDashesAndList(name, revision). + WithDir(repo.Path). + RunStdString(repo.Ctx) return err } @@ -35,7 +38,7 @@ func (repo *Repository) GetTagNameBySHA(sha string) (string, error) { return "", fmt.Errorf("SHA is too short: %s", sha) } - stdout, _, err := gitcmd.NewCommand("show-ref", "--tags", "-d").RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) + stdout, _, err := gitcmd.NewCommand("show-ref", "--tags", "-d").WithDir(repo.Path).RunStdString(repo.Ctx) if err != nil { return "", err } @@ -58,7 +61,7 @@ func (repo *Repository) GetTagNameBySHA(sha string) (string, error) { // GetTagID returns the object ID for a tag (annotated tags have both an object SHA AND a commit SHA) func (repo *Repository) GetTagID(name string) (string, error) { - stdout, _, err := gitcmd.NewCommand("show-ref", "--tags").AddDashesAndList(name).RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) + stdout, _, err := gitcmd.NewCommand("show-ref", "--tags").AddDashesAndList(name).WithDir(repo.Path).RunStdString(repo.Ctx) if err != nil { return "", err } @@ -111,48 +114,42 @@ func (repo *Repository) GetTagInfos(page, pageSize int) ([]*Tag, int, error) { // https://git-scm.com/docs/git-for-each-ref#Documentation/git-for-each-ref.txt-refname forEachRefFmt := foreachref.NewFormat("objecttype", "refname:lstrip=2", "object", "objectname", "creator", "contents", "contents:signature") - stdoutReader, stdoutWriter := io.Pipe() - defer stdoutReader.Close() - defer stdoutWriter.Close() - stderr := strings.Builder{} - rc := &gitcmd.RunOpts{Dir: repo.Path, Stdout: stdoutWriter, Stderr: &stderr} - - go func() { - err := gitcmd.NewCommand("for-each-ref"). - AddOptionFormat("--format=%s", forEachRefFmt.Flag()). - AddArguments("--sort", "-*creatordate", "refs/tags").Run(repo.Ctx, rc) - if err != nil { - _ = stdoutWriter.CloseWithError(gitcmd.ConcatenateError(err, stderr.String())) - } else { - _ = stdoutWriter.Close() - } - }() - var tags []*Tag - parser := forEachRefFmt.Parser(stdoutReader) - for { - ref := parser.Next() - if ref == nil { - break - } + var tagsTotal int + cmd := gitcmd.NewCommand("for-each-ref") + stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe() + defer stdoutReaderClose() + err := cmd.AddOptionFormat("--format=%s", forEachRefFmt.Flag()). + AddArguments("--sort", "-*creatordate", "refs/tags"). + WithDir(repo.Path). + WithPipelineFunc(func(context gitcmd.Context) error { + parser := forEachRefFmt.Parser(stdoutReader) + for { + ref := parser.Next() + if ref == nil { + break + } - tag, err := parseTagRef(ref) - if err != nil { - return nil, 0, fmt.Errorf("GetTagInfos: parse tag: %w", err) - } - tags = append(tags, tag) - } - if err := parser.Err(); err != nil { - return nil, 0, fmt.Errorf("GetTagInfos: parse output: %w", err) - } + tag, err := parseTagRef(ref) + if err != nil { + return fmt.Errorf("GetTagInfos: parse tag: %w", err) + } + tags = append(tags, tag) + } + if err := parser.Err(); err != nil { + return fmt.Errorf("GetTagInfos: parse output: %w", err) + } - sortTagsByTime(tags) - tagsTotal := len(tags) - if page != 0 { - tags = util.PaginateSlice(tags, page, pageSize).([]*Tag) - } + sortTagsByTime(tags) + tagsTotal = len(tags) + if page != 0 { + tags = util.PaginateSlice(tags, page, pageSize).([]*Tag) + } + return nil + }). + RunWithStderr(repo.Ctx) - return tags, tagsTotal, nil + return tags, tagsTotal, err } // parseTagRef parses a tag from a 'git for-each-ref'-produced reference. diff --git a/modules/git/repo_tag_nogogit.go b/modules/git/repo_tag_nogogit.go index 5f79b68a9ae..a9ac0408216 100644 --- a/modules/git/repo_tag_nogogit.go +++ b/modules/git/repo_tag_nogogit.go @@ -24,23 +24,19 @@ func (repo *Repository) IsTagExist(name string) bool { // GetTagType gets the type of the tag, either commit (simple) or tag (annotated) func (repo *Repository) GetTagType(id ObjectID) (string, error) { - wr, rd, cancel, err := repo.CatFileBatchCheck(repo.Ctx) + batch, cancel, err := repo.CatFileBatch(repo.Ctx) if err != nil { return "", err } defer cancel() - _, err = wr.Write([]byte(id.String() + "\n")) - if err != nil { - return "", err - } - _, typ, _, err := ReadBatchLine(rd) + info, err := batch.QueryInfo(id.String()) if err != nil { if IsErrNotExist(err) { return "", ErrNotExist{ID: id.String()} } return "", err } - return typ, nil + return info.Type, nil } func (repo *Repository) getTag(tagID ObjectID, name string) (*Tag, error) { @@ -88,22 +84,20 @@ func (repo *Repository) getTag(tagID ObjectID, name string) (*Tag, error) { } // The tag is an annotated tag with a message. - wr, rd, cancel, err := repo.CatFileBatch(repo.Ctx) + batch, cancel, err := repo.CatFileBatch(repo.Ctx) if err != nil { return nil, err } defer cancel() - if _, err := wr.Write([]byte(tagID.String() + "\n")); err != nil { - return nil, err - } - _, typ, size, err := ReadBatchLine(rd) + info, rd, err := batch.QueryContent(tagID.String()) if err != nil { if errors.Is(err, io.EOF) || IsErrNotExist(err) { return nil, ErrNotExist{ID: tagID.String()} } return nil, err } + typ, size := info.Type, info.Size if typ != "tag" { if err := DiscardFull(rd, size+1); err != nil { return nil, err diff --git a/modules/git/repo_test.go b/modules/git/repo_test.go index 813844d1ea1..776c297a34c 100644 --- a/modules/git/repo_test.go +++ b/modules/git/repo_test.go @@ -10,16 +10,6 @@ import ( "github.com/stretchr/testify/assert" ) -func TestGetLatestCommitTime(t *testing.T) { - bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") - lct, err := GetLatestCommitTime(t.Context(), bareRepo1Path) - assert.NoError(t, err) - // Time is Sun Nov 13 16:40:14 2022 +0100 - // which is the time of commit - // ce064814f4a0d337b333e646ece456cd39fab612 (refs/heads/master) - assert.EqualValues(t, 1668354014, lct.Unix()) -} - func TestRepoIsEmpty(t *testing.T) { emptyRepo2Path := filepath.Join(testReposDir, "repo2_empty") repo, err := OpenRepository(t.Context(), emptyRepo2Path) @@ -29,27 +19,3 @@ func TestRepoIsEmpty(t *testing.T) { assert.NoError(t, err) assert.True(t, isEmpty) } - -func TestRepoGetDivergingCommits(t *testing.T) { - bareRepo1Path := filepath.Join(testReposDir, "repo1_bare") - do, err := GetDivergingCommits(t.Context(), bareRepo1Path, "master", "branch2") - assert.NoError(t, err) - assert.Equal(t, DivergeObject{ - Ahead: 1, - Behind: 5, - }, do) - - do, err = GetDivergingCommits(t.Context(), bareRepo1Path, "master", "master") - assert.NoError(t, err) - assert.Equal(t, DivergeObject{ - Ahead: 0, - Behind: 0, - }, do) - - do, err = GetDivergingCommits(t.Context(), bareRepo1Path, "master", "test") - assert.NoError(t, err) - assert.Equal(t, DivergeObject{ - Ahead: 0, - Behind: 2, - }, do) -} diff --git a/modules/git/repo_tree.go b/modules/git/repo_tree.go index 1d8c9409518..e65e2441edb 100644 --- a/modules/git/repo_tree.go +++ b/modules/git/repo_tree.go @@ -58,17 +58,12 @@ func (repo *Repository) CommitTree(author, committer *Signature, tree *Tree, opt cmd.AddArguments("--no-gpg-sign") } - stdout := new(bytes.Buffer) - stderr := new(bytes.Buffer) - err := cmd.Run(repo.Ctx, &gitcmd.RunOpts{ - Env: env, - Dir: repo.Path, - Stdin: messageBytes, - Stdout: stdout, - Stderr: stderr, - }) + stdout, _, err := cmd.WithEnv(env). + WithDir(repo.Path). + WithStdinBytes(messageBytes.Bytes()). + RunStdString(repo.Ctx) if err != nil { - return nil, gitcmd.ConcatenateError(err, stderr.String()) + return nil, err } - return NewIDFromString(strings.TrimSpace(stdout.String())) + return NewIDFromString(strings.TrimSpace(stdout)) } diff --git a/modules/git/repo_tree_gogit.go b/modules/git/repo_tree_gogit.go index 40524d0c344..89d34e87daf 100644 --- a/modules/git/repo_tree_gogit.go +++ b/modules/git/repo_tree_gogit.go @@ -26,7 +26,7 @@ func (repo *Repository) getTree(id ObjectID) (*Tree, error) { } tree := NewTree(repo, id) - tree.gogitTree = gogitTree + tree.resolvedGogitTreeObject = gogitTree return tree, nil } @@ -38,7 +38,10 @@ func (repo *Repository) GetTree(idStr string) (*Tree, error) { } if len(idStr) != objectFormat.FullLength() { - res, _, err := gitcmd.NewCommand("rev-parse", "--verify").AddDynamicArguments(idStr).RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) + res, _, err := gitcmd.NewCommand("rev-parse", "--verify"). + AddDynamicArguments(idStr). + WithDir(repo.Path). + RunStdString(repo.Ctx) if err != nil { return nil, err } diff --git a/modules/git/repo_tree_nogogit.go b/modules/git/repo_tree_nogogit.go index 1954f851621..82a61072c9d 100644 --- a/modules/git/repo_tree_nogogit.go +++ b/modules/git/repo_tree_nogogit.go @@ -10,24 +10,21 @@ import ( ) func (repo *Repository) getTree(id ObjectID) (*Tree, error) { - wr, rd, cancel, err := repo.CatFileBatch(repo.Ctx) + batch, cancel, err := repo.CatFileBatch(repo.Ctx) if err != nil { return nil, err } defer cancel() - _, _ = wr.Write([]byte(id.String() + "\n")) - - // ignore the SHA - _, typ, size, err := ReadBatchLine(rd) + info, rd, err := batch.QueryContent(id.String()) if err != nil { return nil, err } - switch typ { + switch info.Type { case "tag": resolvedID := id - data, err := io.ReadAll(io.LimitReader(rd, size)) + data, err := io.ReadAll(io.LimitReader(rd, info.Size)) if err != nil { return nil, err } @@ -36,17 +33,14 @@ func (repo *Repository) getTree(id ObjectID) (*Tree, error) { return nil, err } - if _, err := wr.Write([]byte(tag.Object.String() + "\n")); err != nil { - return nil, err - } - commit, err := repo.getCommitFromBatchReader(wr, rd, tag.Object) + commit, err := repo.getCommitWithBatch(batch, tag.Object) if err != nil { return nil, err } commit.Tree.ResolvedID = resolvedID return &commit.Tree, nil case "commit": - commit, err := CommitFromReader(repo, id, io.LimitReader(rd, size)) + commit, err := CommitFromReader(repo, id, io.LimitReader(rd, info.Size)) if err != nil { return nil, err } @@ -62,14 +56,14 @@ func (repo *Repository) getTree(id ObjectID) (*Tree, error) { if err != nil { return nil, err } - tree.entries, err = catBatchParseTreeEntries(objectFormat, tree, rd, size) + tree.entries, err = catBatchParseTreeEntries(objectFormat, tree, rd, info.Size) if err != nil { return nil, err } tree.entriesParsed = true return tree, nil default: - if err := DiscardFull(rd, size+1); err != nil { + if err := DiscardFull(rd, info.Size+1); err != nil { return nil, err } return nil, ErrNotExist{ diff --git a/modules/git/submodule.go b/modules/git/submodule.go index 58824adc825..ed69cbe55d1 100644 --- a/modules/git/submodule.go +++ b/modules/git/submodule.go @@ -7,7 +7,6 @@ import ( "bufio" "context" "fmt" - "os" "code.gitea.io/gitea/modules/git/gitcmd" "code.gitea.io/gitea/modules/log" @@ -21,22 +20,15 @@ type TemplateSubmoduleCommit struct { // GetTemplateSubmoduleCommits returns a list of submodules paths and their commits from a repository // This function is only for generating new repos based on existing template, the template couldn't be too large. func GetTemplateSubmoduleCommits(ctx context.Context, repoPath string) (submoduleCommits []TemplateSubmoduleCommit, _ error) { - stdoutReader, stdoutWriter, err := os.Pipe() - if err != nil { - return nil, err - } - opts := &gitcmd.RunOpts{ - Dir: repoPath, - Stdout: stdoutWriter, - PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error { - _ = stdoutWriter.Close() - defer stdoutReader.Close() - + cmd := gitcmd.NewCommand("ls-tree", "-r", "--", "HEAD") + stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe() + defer stdoutReaderClose() + err := cmd.WithDir(repoPath). + WithPipelineFunc(func(ctx gitcmd.Context) error { scanner := bufio.NewScanner(stdoutReader) for scanner.Scan() { entry, err := parseLsTreeLine(scanner.Bytes()) if err != nil { - cancel() return err } if entry.EntryMode == EntryModeCommit { @@ -44,9 +36,8 @@ func GetTemplateSubmoduleCommits(ctx context.Context, repoPath string) (submodul } } return scanner.Err() - }, - } - err = gitcmd.NewCommand("ls-tree", "-r", "--", "HEAD").Run(ctx, opts) + }). + Run(ctx) if err != nil { return nil, fmt.Errorf("GetTemplateSubmoduleCommits: error running git ls-tree: %v", err) } @@ -58,7 +49,7 @@ func GetTemplateSubmoduleCommits(ctx context.Context, repoPath string) (submodul func AddTemplateSubmoduleIndexes(ctx context.Context, repoPath string, submodules []TemplateSubmoduleCommit) error { for _, submodule := range submodules { cmd := gitcmd.NewCommand("update-index", "--add", "--cacheinfo", "160000").AddDynamicArguments(submodule.Commit, submodule.Path) - if stdout, _, err := cmd.RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath}); err != nil { + if stdout, _, err := cmd.WithDir(repoPath).RunStdString(ctx); err != nil { log.Error("Unable to add %s as submodule to repo %s: stdout %s\nError: %v", submodule.Path, repoPath, stdout, err) return err } diff --git a/modules/git/submodule_test.go b/modules/git/submodule_test.go index d2df8b2a911..22bd5bf71e5 100644 --- a/modules/git/submodule_test.go +++ b/modules/git/submodule_test.go @@ -32,14 +32,14 @@ func TestAddTemplateSubmoduleIndexes(t *testing.T) { ctx := t.Context() tmpDir := t.TempDir() var err error - _, _, err = gitcmd.NewCommand("init").RunStdString(ctx, &gitcmd.RunOpts{Dir: tmpDir}) + _, _, err = gitcmd.NewCommand("init").WithDir(tmpDir).RunStdString(ctx) require.NoError(t, err) _ = os.Mkdir(filepath.Join(tmpDir, "new-dir"), 0o755) err = AddTemplateSubmoduleIndexes(ctx, tmpDir, []TemplateSubmoduleCommit{{Path: "new-dir", Commit: "1234567890123456789012345678901234567890"}}) require.NoError(t, err) - _, _, err = gitcmd.NewCommand("add", "--all").RunStdString(ctx, &gitcmd.RunOpts{Dir: tmpDir}) + _, _, err = gitcmd.NewCommand("add", "--all").WithDir(tmpDir).RunStdString(ctx) require.NoError(t, err) - _, _, err = gitcmd.NewCommand("-c", "user.name=a", "-c", "user.email=b", "commit", "-m=test").RunStdString(ctx, &gitcmd.RunOpts{Dir: tmpDir}) + _, _, err = gitcmd.NewCommand("-c", "user.name=a", "-c", "user.email=b", "commit", "-m=test").WithDir(tmpDir).RunStdString(ctx) require.NoError(t, err) submodules, err := GetTemplateSubmoduleCommits(t.Context(), tmpDir) require.NoError(t, err) diff --git a/modules/git/tree.go b/modules/git/tree.go index a8c4929c7c9..c1898b20cbe 100644 --- a/modules/git/tree.go +++ b/modules/git/tree.go @@ -11,11 +11,21 @@ import ( "code.gitea.io/gitea/modules/git/gitcmd" ) +type TreeCommon struct { + ID ObjectID + ResolvedID ObjectID + + repo *Repository + ptree *Tree // parent tree +} + // NewTree create a new tree according the repository and tree id func NewTree(repo *Repository, id ObjectID) *Tree { return &Tree{ - ID: id, - repo: repo, + TreeCommon: TreeCommon{ + ID: id, + repo: repo, + }, } } @@ -53,7 +63,7 @@ func (repo *Repository) LsTree(ref string, filenames ...string) ([]string, error cmd := gitcmd.NewCommand("ls-tree", "-z", "--name-only"). AddDashesAndList(append([]string{ref}, filenames...)...) - res, _, err := cmd.RunStdBytes(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) + res, _, err := cmd.WithDir(repo.Path).RunStdBytes(repo.Ctx) if err != nil { return nil, err } @@ -69,7 +79,8 @@ func (repo *Repository) LsTree(ref string, filenames ...string) ([]string, error func (repo *Repository) GetTreePathLatestCommit(refName, treePath string) (*Commit, error) { stdout, _, err := gitcmd.NewCommand("rev-list", "-1"). AddDynamicArguments(refName).AddDashesAndList(treePath). - RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path}) + WithDir(repo.Path). + RunStdString(repo.Ctx) if err != nil { return nil, err } diff --git a/modules/git/tree_blob_gogit.go b/modules/git/tree_blob_gogit.go index f29e8f8b9e8..2c0ff0e1b0d 100644 --- a/modules/git/tree_blob_gogit.go +++ b/modules/git/tree_blob_gogit.go @@ -11,22 +11,16 @@ import ( "strings" "github.com/go-git/go-git/v5/plumbing" - "github.com/go-git/go-git/v5/plumbing/filemode" - "github.com/go-git/go-git/v5/plumbing/object" ) // GetTreeEntryByPath get the tree entries according the sub dir func (t *Tree) GetTreeEntryByPath(relpath string) (*TreeEntry, error) { if len(relpath) == 0 { return &TreeEntry{ - ID: t.ID, - // Type: ObjectTree, - ptree: t, - gogitTreeEntry: &object.TreeEntry{ - Name: "", - Mode: filemode.Dir, - Hash: plumbing.Hash(t.ID.RawValue()), - }, + ID: t.ID, + ptree: t, + name: "", + entryMode: EntryModeTree, }, nil } diff --git a/modules/git/tree_entry.go b/modules/git/tree_entry.go index 5099d8ee79b..e7e4ea2d5bd 100644 --- a/modules/git/tree_entry.go +++ b/modules/git/tree_entry.go @@ -6,12 +6,60 @@ package git import ( "path" - "sort" + "slices" "strings" "code.gitea.io/gitea/modules/util" ) +// TreeEntry the leaf in the git tree +type TreeEntry struct { + ID ObjectID + + name string + ptree *Tree + + entryMode EntryMode + + size int64 + sized bool +} + +// Name returns the name of the entry (base name) +func (te *TreeEntry) Name() string { + return te.name +} + +// Mode returns the mode of the entry +func (te *TreeEntry) Mode() EntryMode { + return te.entryMode +} + +// IsSubModule if the entry is a submodule +func (te *TreeEntry) IsSubModule() bool { + return te.entryMode.IsSubModule() +} + +// IsDir if the entry is a sub dir +func (te *TreeEntry) IsDir() bool { + return te.entryMode.IsDir() +} + +// IsLink if the entry is a symlink +func (te *TreeEntry) IsLink() bool { + return te.entryMode.IsLink() +} + +// IsRegular if the entry is a regular file +func (te *TreeEntry) IsRegular() bool { + return te.entryMode.IsRegular() +} + +// IsExecutable if the entry is an executable file (not necessarily binary) +func (te *TreeEntry) IsExecutable() bool { + return te.entryMode.IsExecutable() +} + // Type returns the type of the entry (commit, tree, blob) func (te *TreeEntry) Type() string { switch te.Mode() { @@ -109,49 +157,16 @@ func (te *TreeEntry) GetSubJumpablePathName() string { // Entries a list of entry type Entries []*TreeEntry -type customSortableEntries struct { - Comparer func(s1, s2 string) bool - Entries -} - -var sorter = []func(t1, t2 *TreeEntry, cmp func(s1, s2 string) bool) bool{ - func(t1, t2 *TreeEntry, cmp func(s1, s2 string) bool) bool { - return (t1.IsDir() || t1.IsSubModule()) && !t2.IsDir() && !t2.IsSubModule() - }, - func(t1, t2 *TreeEntry, cmp func(s1, s2 string) bool) bool { - return cmp(t1.Name(), t2.Name()) - }, -} - -func (ctes customSortableEntries) Len() int { return len(ctes.Entries) } - -func (ctes customSortableEntries) Swap(i, j int) { - ctes.Entries[i], ctes.Entries[j] = ctes.Entries[j], ctes.Entries[i] -} - -func (ctes customSortableEntries) Less(i, j int) bool { - t1, t2 := ctes.Entries[i], ctes.Entries[j] - var k int - for k = 0; k < len(sorter)-1; k++ { - s := sorter[k] - switch { - case s(t1, t2, ctes.Comparer): - return true - case s(t2, t1, ctes.Comparer): - return false - } - } - return sorter[k](t1, t2, ctes.Comparer) -} - -// Sort sort the list of entry -func (tes Entries) Sort() { - sort.Sort(customSortableEntries{func(s1, s2 string) bool { - return s1 < s2 - }, tes}) -} - // CustomSort customizable string comparing sort entry list -func (tes Entries) CustomSort(cmp func(s1, s2 string) bool) { - sort.Sort(customSortableEntries{cmp, tes}) +func (tes Entries) CustomSort(cmp func(s1, s2 string) int) { + slices.SortFunc(tes, func(a, b *TreeEntry) int { + s1Dir, s2Dir := a.IsDir() || a.IsSubModule(), b.IsDir() || b.IsSubModule() + if s1Dir != s2Dir { + if s1Dir { + return -1 + } + return 1 + } + return cmp(a.Name(), b.Name()) + }) } diff --git a/modules/git/tree_entry_gogit.go b/modules/git/tree_entry_gogit.go index e6845f1c776..f092e70636b 100644 --- a/modules/git/tree_entry_gogit.go +++ b/modules/git/tree_entry_gogit.go @@ -12,25 +12,21 @@ import ( "github.com/go-git/go-git/v5/plumbing/object" ) -// TreeEntry the leaf in the git tree -type TreeEntry struct { - ID ObjectID - - gogitTreeEntry *object.TreeEntry - ptree *Tree - - size int64 - sized bool +// gogitFileModeToEntryMode converts go-git filemode to EntryMode +func gogitFileModeToEntryMode(mode filemode.FileMode) EntryMode { + return EntryMode(mode) } -// Name returns the name of the entry -func (te *TreeEntry) Name() string { - return te.gogitTreeEntry.Name +func entryModeToGogitFileMode(mode EntryMode) filemode.FileMode { + return filemode.FileMode(mode) } -// Mode returns the mode of the entry -func (te *TreeEntry) Mode() EntryMode { - return EntryMode(te.gogitTreeEntry.Mode) +func (te *TreeEntry) toGogitTreeEntry() *object.TreeEntry { + return &object.TreeEntry{ + Name: te.name, + Mode: entryModeToGogitFileMode(te.entryMode), + Hash: plumbing.Hash(te.ID.RawValue()), + } } // Size returns the size of the entry @@ -41,7 +37,11 @@ func (te *TreeEntry) Size() int64 { return te.size } - file, err := te.ptree.gogitTree.TreeEntryFile(te.gogitTreeEntry) + ptreeGogitTree, err := te.ptree.gogitTreeObject() + if err != nil { + return 0 + } + file, err := ptreeGogitTree.TreeEntryFile(te.toGogitTreeEntry()) if err != nil { return 0 } @@ -51,41 +51,11 @@ func (te *TreeEntry) Size() int64 { return te.size } -// IsSubModule if the entry is a submodule -func (te *TreeEntry) IsSubModule() bool { - return te.gogitTreeEntry.Mode == filemode.Submodule -} - -// IsDir if the entry is a sub dir -func (te *TreeEntry) IsDir() bool { - return te.gogitTreeEntry.Mode == filemode.Dir -} - -// IsLink if the entry is a symlink -func (te *TreeEntry) IsLink() bool { - return te.gogitTreeEntry.Mode == filemode.Symlink -} - -// IsRegular if the entry is a regular file -func (te *TreeEntry) IsRegular() bool { - return te.gogitTreeEntry.Mode == filemode.Regular -} - -// IsExecutable if the entry is an executable file (not necessarily binary) -func (te *TreeEntry) IsExecutable() bool { - return te.gogitTreeEntry.Mode == filemode.Executable -} - // Blob returns the blob object the entry func (te *TreeEntry) Blob() *Blob { - encodedObj, err := te.ptree.repo.gogitRepo.Storer.EncodedObject(plumbing.AnyObject, te.gogitTreeEntry.Hash) - if err != nil { - return nil - } - return &Blob{ - ID: ParseGogitHash(te.gogitTreeEntry.Hash), - gogitEncodedObj: encodedObj, - name: te.Name(), + ID: te.ID, + repo: te.ptree.repo, + name: te.Name(), } } diff --git a/modules/git/tree_entry_gogit_test.go b/modules/git/tree_entry_gogit_test.go new file mode 100644 index 00000000000..ed14b45e9e0 --- /dev/null +++ b/modules/git/tree_entry_gogit_test.go @@ -0,0 +1,27 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build gogit + +package git + +import ( + "testing" + + "github.com/go-git/go-git/v5/plumbing/filemode" + "github.com/stretchr/testify/assert" +) + +func TestEntryGogit(t *testing.T) { + cases := map[EntryMode]filemode.FileMode{ + EntryModeBlob: filemode.Regular, + EntryModeCommit: filemode.Submodule, + EntryModeExec: filemode.Executable, + EntryModeSymlink: filemode.Symlink, + EntryModeTree: filemode.Dir, + } + for emode, fmode := range cases { + assert.EqualValues(t, fmode, entryModeToGogitFileMode(emode)) + assert.EqualValues(t, emode, gogitFileModeToEntryMode(fmode)) + } +} diff --git a/modules/git/tree_entry_mode.go b/modules/git/tree_entry_mode.go index f36c07bc2a0..2ceba113740 100644 --- a/modules/git/tree_entry_mode.go +++ b/modules/git/tree_entry_mode.go @@ -4,7 +4,6 @@ package git import ( - "fmt" "strconv" ) @@ -55,21 +54,38 @@ func (e EntryMode) IsExecutable() bool { return e == EntryModeExec } -func ParseEntryMode(mode string) (EntryMode, error) { +func ParseEntryMode(mode string) EntryMode { switch mode { case "000000": - return EntryModeNoEntry, nil + return EntryModeNoEntry case "100644": - return EntryModeBlob, nil + return EntryModeBlob case "100755": - return EntryModeExec, nil + return EntryModeExec case "120000": - return EntryModeSymlink, nil + return EntryModeSymlink case "160000": - return EntryModeCommit, nil - case "040000", "040755": // git uses 040000 for tree object, but some users may get 040755 for unknown reasons - return EntryModeTree, nil + return EntryModeCommit + case "040000": + return EntryModeTree default: - return 0, fmt.Errorf("unparsable entry mode: %s", mode) + // git uses 040000 for tree object, but some users may get 040755 from non-standard git implementations + m, _ := strconv.ParseInt(mode, 8, 32) + modeInt := EntryMode(m) + switch modeInt & 0o770000 { + case 0o040000: + return EntryModeTree + case 0o160000: + return EntryModeCommit + case 0o120000: + return EntryModeSymlink + case 0o100000: + if modeInt&0o777 == 0o755 { + return EntryModeExec + } + return EntryModeBlob + default: + return EntryModeNoEntry + } } } diff --git a/modules/git/tree_entry_nogogit.go b/modules/git/tree_entry_nogogit.go index 8fad96cdf89..0a19b38d3e6 100644 --- a/modules/git/tree_entry_nogogit.go +++ b/modules/git/tree_entry_nogogit.go @@ -7,27 +7,6 @@ package git import "code.gitea.io/gitea/modules/log" -// TreeEntry the leaf in the git tree -type TreeEntry struct { - ID ObjectID - ptree *Tree - - entryMode EntryMode - name string - size int64 - sized bool -} - -// Name returns the name of the entry (base name) -func (te *TreeEntry) Name() string { - return te.name -} - -// Mode returns the mode of the entry -func (te *TreeEntry) Mode() EntryMode { - return te.entryMode -} - // Size returns the size of the entry func (te *TreeEntry) Size() int64 { if te.IsDir() { @@ -36,52 +15,23 @@ func (te *TreeEntry) Size() int64 { return te.size } - wr, rd, cancel, err := te.ptree.repo.CatFileBatchCheck(te.ptree.repo.Ctx) + batch, cancel, err := te.ptree.repo.CatFileBatch(te.ptree.repo.Ctx) if err != nil { log.Debug("error whilst reading size for %s in %s. Error: %v", te.ID.String(), te.ptree.repo.Path, err) return 0 } defer cancel() - _, err = wr.Write([]byte(te.ID.String() + "\n")) - if err != nil { - log.Debug("error whilst reading size for %s in %s. Error: %v", te.ID.String(), te.ptree.repo.Path, err) - return 0 - } - _, _, te.size, err = ReadBatchLine(rd) + info, err := batch.QueryInfo(te.ID.String()) if err != nil { log.Debug("error whilst reading size for %s in %s. Error: %v", te.ID.String(), te.ptree.repo.Path, err) return 0 } + te.size = info.Size te.sized = true return te.size } -// IsSubModule if the entry is a submodule -func (te *TreeEntry) IsSubModule() bool { - return te.entryMode.IsSubModule() -} - -// IsDir if the entry is a sub dir -func (te *TreeEntry) IsDir() bool { - return te.entryMode.IsDir() -} - -// IsLink if the entry is a symlink -func (te *TreeEntry) IsLink() bool { - return te.entryMode.IsLink() -} - -// IsRegular if the entry is a regular file -func (te *TreeEntry) IsRegular() bool { - return te.entryMode.IsRegular() -} - -// IsExecutable if the entry is an executable file (not necessarily binary) -func (te *TreeEntry) IsExecutable() bool { - return te.entryMode.IsExecutable() -} - // Blob returns the blob object the entry func (te *TreeEntry) Blob() *Blob { return &Blob{ diff --git a/modules/git/tree_entry_test.go b/modules/git/tree_entry_test.go index 9ca82675e07..3df6eeab68d 100644 --- a/modules/git/tree_entry_test.go +++ b/modules/git/tree_entry_test.go @@ -1,55 +1,57 @@ // Copyright 2017 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -//go:build gogit - package git import ( + "math/rand/v2" + "slices" + "strings" "testing" - "github.com/go-git/go-git/v5/plumbing/filemode" - "github.com/go-git/go-git/v5/plumbing/object" "github.com/stretchr/testify/assert" ) -func getTestEntries() Entries { - return Entries{ - &TreeEntry{gogitTreeEntry: &object.TreeEntry{Name: "v1.0", Mode: filemode.Dir}}, - &TreeEntry{gogitTreeEntry: &object.TreeEntry{Name: "v2.0", Mode: filemode.Dir}}, - &TreeEntry{gogitTreeEntry: &object.TreeEntry{Name: "v2.1", Mode: filemode.Dir}}, - &TreeEntry{gogitTreeEntry: &object.TreeEntry{Name: "v2.12", Mode: filemode.Dir}}, - &TreeEntry{gogitTreeEntry: &object.TreeEntry{Name: "v2.2", Mode: filemode.Dir}}, - &TreeEntry{gogitTreeEntry: &object.TreeEntry{Name: "v12.0", Mode: filemode.Dir}}, - &TreeEntry{gogitTreeEntry: &object.TreeEntry{Name: "abc", Mode: filemode.Regular}}, - &TreeEntry{gogitTreeEntry: &object.TreeEntry{Name: "bcd", Mode: filemode.Regular}}, +func TestEntriesCustomSort(t *testing.T) { + entries := Entries{ + &TreeEntry{name: "a-dir", entryMode: EntryModeTree}, + &TreeEntry{name: "a-submodule", entryMode: EntryModeCommit}, + &TreeEntry{name: "b-dir", entryMode: EntryModeTree}, + &TreeEntry{name: "b-submodule", entryMode: EntryModeCommit}, + &TreeEntry{name: "a-file", entryMode: EntryModeBlob}, + &TreeEntry{name: "b-file", entryMode: EntryModeBlob}, + } + expected := slices.Clone(entries) + for slices.Equal(expected, entries) { + rand.Shuffle(len(entries), func(i, j int) { entries[i], entries[j] = entries[j], entries[i] }) + } + entries.CustomSort(strings.Compare) + assert.Equal(t, expected, entries) +} + +func TestParseEntryMode(t *testing.T) { + tests := []struct { + modeStr string + expectMod EntryMode + }{ + {"000000", EntryModeNoEntry}, + {"000755", EntryModeNoEntry}, + + {"100644", EntryModeBlob}, + {"100755", EntryModeExec}, + + {"120000", EntryModeSymlink}, + {"120755", EntryModeSymlink}, + {"160000", EntryModeCommit}, + {"160755", EntryModeCommit}, + + {"040000", EntryModeTree}, + {"040755", EntryModeTree}, + + {"777777", EntryModeNoEntry}, // invalid mode + } + for _, test := range tests { + mod := ParseEntryMode(test.modeStr) + assert.Equal(t, test.expectMod, mod, "modeStr: %s", test.modeStr) } } - -func TestEntriesSort(t *testing.T) { - entries := getTestEntries() - entries.Sort() - assert.Equal(t, "v1.0", entries[0].Name()) - assert.Equal(t, "v12.0", entries[1].Name()) - assert.Equal(t, "v2.0", entries[2].Name()) - assert.Equal(t, "v2.1", entries[3].Name()) - assert.Equal(t, "v2.12", entries[4].Name()) - assert.Equal(t, "v2.2", entries[5].Name()) - assert.Equal(t, "abc", entries[6].Name()) - assert.Equal(t, "bcd", entries[7].Name()) -} - -func TestEntriesCustomSort(t *testing.T) { - entries := getTestEntries() - entries.CustomSort(func(s1, s2 string) bool { - return s1 > s2 - }) - assert.Equal(t, "v2.2", entries[0].Name()) - assert.Equal(t, "v2.12", entries[1].Name()) - assert.Equal(t, "v2.1", entries[2].Name()) - assert.Equal(t, "v2.0", entries[3].Name()) - assert.Equal(t, "v12.0", entries[4].Name()) - assert.Equal(t, "v1.0", entries[5].Name()) - assert.Equal(t, "bcd", entries[6].Name()) - assert.Equal(t, "abc", entries[7].Name()) -} diff --git a/modules/git/tree_gogit.go b/modules/git/tree_gogit.go index 272b018ffdd..fec6e2704e6 100644 --- a/modules/git/tree_gogit.go +++ b/modules/git/tree_gogit.go @@ -15,41 +15,34 @@ import ( // Tree represents a flat directory listing. type Tree struct { - ID ObjectID - ResolvedID ObjectID - repo *Repository + TreeCommon - gogitTree *object.Tree - - // parent tree - ptree *Tree + resolvedGogitTreeObject *object.Tree } -func (t *Tree) loadTreeObject() error { - gogitTree, err := t.repo.gogitRepo.TreeObject(plumbing.Hash(t.ID.RawValue())) - if err != nil { - return err - } - - t.gogitTree = gogitTree - return nil -} - -// ListEntries returns all entries of current tree. -func (t *Tree) ListEntries() (Entries, error) { - if t.gogitTree == nil { - err := t.loadTreeObject() +func (t *Tree) gogitTreeObject() (_ *object.Tree, err error) { + if t.resolvedGogitTreeObject == nil { + t.resolvedGogitTreeObject, err = t.repo.gogitRepo.TreeObject(plumbing.Hash(t.ID.RawValue())) if err != nil { return nil, err } } + return t.resolvedGogitTreeObject, nil +} - entries := make([]*TreeEntry, len(t.gogitTree.Entries)) - for i, entry := range t.gogitTree.Entries { +// ListEntries returns all entries of current tree. +func (t *Tree) ListEntries() (Entries, error) { + gogitTree, err := t.gogitTreeObject() + if err != nil { + return nil, err + } + entries := make([]*TreeEntry, len(gogitTree.Entries)) + for i, gogitTreeEntry := range gogitTree.Entries { entries[i] = &TreeEntry{ - ID: ParseGogitHash(entry.Hash), - gogitTreeEntry: &t.gogitTree.Entries[i], - ptree: t, + ID: ParseGogitHash(gogitTreeEntry.Hash), + ptree: t, + name: gogitTreeEntry.Name, + entryMode: gogitFileModeToEntryMode(gogitTreeEntry.Mode), } } @@ -57,37 +50,28 @@ func (t *Tree) ListEntries() (Entries, error) { } // ListEntriesRecursiveWithSize returns all entries of current tree recursively including all subtrees -func (t *Tree) ListEntriesRecursiveWithSize() (Entries, error) { - if t.gogitTree == nil { - err := t.loadTreeObject() - if err != nil { - return nil, err - } +func (t *Tree) ListEntriesRecursiveWithSize() (entries Entries, _ error) { + gogitTree, err := t.gogitTreeObject() + if err != nil { + return nil, err } - var entries []*TreeEntry - seen := map[plumbing.Hash]bool{} - walker := object.NewTreeWalker(t.gogitTree, true, seen) + walker := object.NewTreeWalker(gogitTree, true, nil) for { - _, entry, err := walker.Next() + fullName, gogitTreeEntry, err := walker.Next() if err == io.EOF { break - } - if err != nil { + } else if err != nil { return nil, err } - if seen[entry.Hash] { - continue - } - convertedEntry := &TreeEntry{ - ID: ParseGogitHash(entry.Hash), - gogitTreeEntry: &entry, - ptree: t, + ID: ParseGogitHash(gogitTreeEntry.Hash), + name: fullName, // FIXME: the "name" field is abused, here it is a full path + ptree: t, // FIXME: this ptree is not right, fortunately it isn't really used + entryMode: gogitFileModeToEntryMode(gogitTreeEntry.Mode), } entries = append(entries, convertedEntry) } - return entries, nil } diff --git a/modules/git/tree_nogogit.go b/modules/git/tree_nogogit.go index 045d78c42c6..5d951dad0dc 100644 --- a/modules/git/tree_nogogit.go +++ b/modules/git/tree_nogogit.go @@ -14,18 +14,10 @@ import ( // Tree represents a flat directory listing. type Tree struct { - ID ObjectID - ResolvedID ObjectID - repo *Repository - - // parent tree - ptree *Tree + TreeCommon entries Entries entriesParsed bool - - entriesRecursive Entries - entriesRecursiveParsed bool } // ListEntries returns all entries of current tree. @@ -35,30 +27,29 @@ func (t *Tree) ListEntries() (Entries, error) { } if t.repo != nil { - wr, rd, cancel, err := t.repo.CatFileBatch(t.repo.Ctx) + batch, cancel, err := t.repo.CatFileBatch(t.repo.Ctx) if err != nil { return nil, err } defer cancel() - _, _ = wr.Write([]byte(t.ID.String() + "\n")) - _, typ, sz, err := ReadBatchLine(rd) + info, rd, err := batch.QueryContent(t.ID.String()) if err != nil { return nil, err } - if typ == "commit" { - treeID, err := ReadTreeID(rd, sz) + + if info.Type == "commit" { + treeID, err := ReadTreeID(rd, info.Size) if err != nil && err != io.EOF { return nil, err } - _, _ = wr.Write([]byte(treeID + "\n")) - _, typ, sz, err = ReadBatchLine(rd) + info, rd, err = batch.QueryContent(treeID) if err != nil { return nil, err } } - if typ == "tree" { - t.entries, err = catBatchParseTreeEntries(t.ID.Type(), t, rd, sz) + if info.Type == "tree" { + t.entries, err = catBatchParseTreeEntries(t.ID.Type(), t, rd, info.Size) if err != nil { return nil, err } @@ -67,14 +58,14 @@ func (t *Tree) ListEntries() (Entries, error) { } // Not a tree just use ls-tree instead - if err := DiscardFull(rd, sz+1); err != nil { + if err := DiscardFull(rd, info.Size+1); err != nil { return nil, err } } - stdout, _, runErr := gitcmd.NewCommand("ls-tree", "-l").AddDynamicArguments(t.ID.String()).RunStdBytes(t.repo.Ctx, &gitcmd.RunOpts{Dir: t.repo.Path}) + stdout, _, runErr := gitcmd.NewCommand("ls-tree", "-l").AddDynamicArguments(t.ID.String()).WithDir(t.repo.Path).RunStdBytes(t.repo.Ctx) if runErr != nil { - if strings.Contains(runErr.Error(), "fatal: Not a valid object name") || strings.Contains(runErr.Error(), "fatal: not a tree object") { + if gitcmd.IsStdErrorNotValidObjectName(runErr) || strings.Contains(runErr.Error(), "fatal: not a tree object") { return nil, ErrNotExist{ ID: t.ID.String(), } @@ -94,25 +85,18 @@ func (t *Tree) ListEntries() (Entries, error) { // listEntriesRecursive returns all entries of current tree recursively including all subtrees // extraArgs could be "-l" to get the size, which is slower func (t *Tree) listEntriesRecursive(extraArgs gitcmd.TrustedCmdArgs) (Entries, error) { - if t.entriesRecursiveParsed { - return t.entriesRecursive, nil - } - stdout, _, runErr := gitcmd.NewCommand("ls-tree", "-t", "-r"). AddArguments(extraArgs...). AddDynamicArguments(t.ID.String()). - RunStdBytes(t.repo.Ctx, &gitcmd.RunOpts{Dir: t.repo.Path}) + WithDir(t.repo.Path). + RunStdBytes(t.repo.Ctx) if runErr != nil { return nil, runErr } - var err error - t.entriesRecursive, err = parseTreeEntries(stdout, t) - if err == nil { - t.entriesRecursiveParsed = true - } - - return t.entriesRecursive, err + // FIXME: the "name" field is abused, here it is a full path + // FIXME: this ptree is not right, fortunately it isn't really used + return parseTreeEntries(stdout, t) } // ListEntriesRecursiveFast returns all entries of current tree recursively including all subtrees, no size diff --git a/modules/git/url/url_test.go b/modules/git/url/url_test.go index 6655c20be32..76aa74a1287 100644 --- a/modules/git/url/url_test.go +++ b/modules/git/url/url_test.go @@ -34,12 +34,12 @@ func TestParseGitURLs(t *testing.T) { }, }, { - kase: "git@[fe80:14fc:cec5:c174:d88%2510]:go-gitea/gitea.git", + kase: "git@[fe80::14fc:cec5:c174:d88%2510]:go-gitea/gitea.git", expected: &GitURL{ URL: &url.URL{ Scheme: "ssh", User: url.User("git"), - Host: "[fe80:14fc:cec5:c174:d88%10]", + Host: "[fe80::14fc:cec5:c174:d88%10]", Path: "go-gitea/gitea.git", }, extraMark: 1, @@ -137,11 +137,11 @@ func TestParseGitURLs(t *testing.T) { }, }, { - kase: "https://[fe80:14fc:cec5:c174:d88%2510]:20/go-gitea/gitea.git", + kase: "https://[fe80::14fc:cec5:c174:d88%2510]:20/go-gitea/gitea.git", expected: &GitURL{ URL: &url.URL{ Scheme: "https", - Host: "[fe80:14fc:cec5:c174:d88%10]:20", + Host: "[fe80::14fc:cec5:c174:d88%10]:20", Path: "/go-gitea/gitea.git", }, extraMark: 0, diff --git a/modules/git/utils.go b/modules/git/utils.go index b5f188904a7..e7d30ce9eee 100644 --- a/modules/git/utils.go +++ b/modules/git/utils.go @@ -6,7 +6,6 @@ package git import ( "crypto/sha1" "encoding/hex" - "io" "strconv" "strings" "sync" @@ -68,32 +67,6 @@ func ParseBool(value string) (result, valid bool) { return intValue != 0, true } -// LimitedReaderCloser is a limited reader closer -type LimitedReaderCloser struct { - R io.Reader - C io.Closer - N int64 -} - -// Read implements io.Reader -func (l *LimitedReaderCloser) Read(p []byte) (n int, err error) { - if l.N <= 0 { - _ = l.C.Close() - return 0, io.EOF - } - if int64(len(p)) > l.N { - p = p[0:l.N] - } - n, err = l.R.Read(p) - l.N -= int64(n) - return n, err -} - -// Close implements io.Closer -func (l *LimitedReaderCloser) Close() error { - return l.C.Close() -} - func HashFilePathForWebUI(s string) string { h := sha1.New() _, _ = h.Write([]byte(s)) diff --git a/modules/gitrepo/archive.go b/modules/gitrepo/archive.go new file mode 100644 index 00000000000..191a1bd2c03 --- /dev/null +++ b/modules/gitrepo/archive.go @@ -0,0 +1,80 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "context" + "fmt" + "io" + "os" + "path" + "path/filepath" + "slices" + "strings" + + "code.gitea.io/gitea/modules/git/gitcmd" + "code.gitea.io/gitea/modules/setting" +) + +// CreateArchive create archive content to the target path +func CreateArchive(ctx context.Context, repo Repository, format string, target io.Writer, usePrefix bool, commitID string, paths []string) error { + if format == "unknown" { + return fmt.Errorf("unknown format: %v", format) + } + + cmd := gitcmd.NewCommand("archive") + if usePrefix { + cmd.AddOptionFormat("--prefix=%s", filepath.Base(strings.TrimSuffix(repo.RelativePath(), ".git"))+"/") + } + cmd.AddOptionFormat("--format=%s", format) + cmd.AddDynamicArguments(commitID) + + paths = slices.Clone(paths) + for i := range paths { + // although "git archive" already ensures the paths won't go outside the repo, we still clean them here for safety + paths[i] = path.Clean(paths[i]) + } + cmd.AddDynamicArguments(paths...) + return RunCmdWithStderr(ctx, repo, cmd.WithStdoutCopy(target)) +} + +// CreateBundle create bundle content to the target path +func CreateBundle(ctx context.Context, repo Repository, commit string, out io.Writer) error { + tmp, cleanup, err := setting.AppDataTempDir("git-repo-content").MkdirTempRandom("gitea-bundle") + if err != nil { + return err + } + defer cleanup() + + env := append(os.Environ(), "GIT_OBJECT_DIRECTORY="+filepath.Join(repoPath(repo), "objects")) + _, _, err = gitcmd.NewCommand("init", "--bare").WithDir(tmp).WithEnv(env).RunStdString(ctx) + if err != nil { + return err + } + + _, _, err = gitcmd.NewCommand("reset", "--soft").AddDynamicArguments(commit).WithDir(tmp).WithEnv(env).RunStdString(ctx) + if err != nil { + return err + } + + _, _, err = gitcmd.NewCommand("branch", "-m", "bundle").WithDir(tmp).WithEnv(env).RunStdString(ctx) + if err != nil { + return err + } + + tmpFile := filepath.Join(tmp, "bundle") + _, _, err = gitcmd.NewCommand("bundle", "create").AddDynamicArguments(tmpFile, "bundle", "HEAD").WithDir(tmp).WithEnv(env).RunStdString(ctx) + if err != nil { + return err + } + + fi, err := os.Open(tmpFile) + if err != nil { + return err + } + defer fi.Close() + + _, err = io.Copy(out, fi) + return err +} diff --git a/modules/git/blame.go b/modules/gitrepo/blame.go similarity index 70% rename from modules/git/blame.go rename to modules/gitrepo/blame.go index 50cadc41c22..2352da17604 100644 --- a/modules/git/blame.go +++ b/modules/gitrepo/blame.go @@ -1,20 +1,28 @@ -// Copyright 2019 The Gitea Authors. All rights reserved. +// Copyright 2025 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -package git +package gitrepo import ( "bufio" "bytes" "context" "io" - "os" + "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git/gitcmd" - "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" ) +func LineBlame(ctx context.Context, repo Repository, revision, file string, line uint) (string, error) { + stdout, _, err := RunCmdString(ctx, repo, + gitcmd.NewCommand("blame"). + AddOptionFormat("-L %d,%d", line, line). + AddOptionValues("-p", revision). + AddDashesAndList(file)) + return stdout, err +} + // BlamePart represents block of blame - continuous lines with one sha type BlamePart struct { Sha string @@ -25,13 +33,11 @@ type BlamePart struct { // BlameReader returns part of file blame one by one type BlameReader struct { - output io.WriteCloser - reader io.ReadCloser bufferedReader *bufio.Reader done chan error lastSha *string ignoreRevsFile string - objectFormat ObjectFormat + objectFormat git.ObjectFormat cleanupFuncs []func() } @@ -122,34 +128,42 @@ func (r *BlameReader) Close() error { err := <-r.done r.bufferedReader = nil - _ = r.reader.Close() - _ = r.output.Close() - for _, cleanup := range r.cleanupFuncs { - if cleanup != nil { - cleanup() - } - } + r.cleanup() return err } +func (r *BlameReader) cleanup() { + for _, cleanup := range r.cleanupFuncs { + cleanup() + } +} + // CreateBlameReader creates reader for given repository, commit and file -func CreateBlameReader(ctx context.Context, objectFormat ObjectFormat, repoPath string, commit *Commit, file string, bypassBlameIgnore bool) (rd *BlameReader, err error) { - var ignoreRevsFileName string - var ignoreRevsFileCleanup func() +func CreateBlameReader(ctx context.Context, objectFormat git.ObjectFormat, repo Repository, commit *git.Commit, file string, bypassBlameIgnore bool) (rd *BlameReader, retErr error) { defer func() { - if err != nil && ignoreRevsFileCleanup != nil { - ignoreRevsFileCleanup() + if retErr != nil { + rd.cleanup() } }() + rd = &BlameReader{ + done: make(chan error, 1), + objectFormat: objectFormat, + } + cmd := gitcmd.NewCommand("blame", "--porcelain") - if DefaultFeatures().CheckVersionAtLeast("2.23") && !bypassBlameIgnore { - ignoreRevsFileName, ignoreRevsFileCleanup, err = tryCreateBlameIgnoreRevsFile(commit) - if err != nil && !IsErrNotExist(err) { + stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe() + rd.bufferedReader = bufio.NewReader(stdoutReader) + rd.cleanupFuncs = append(rd.cleanupFuncs, stdoutReaderClose) + + if git.DefaultFeatures().CheckVersionAtLeast("2.23") && !bypassBlameIgnore { + ignoreRevsFileName, ignoreRevsFileCleanup, err := tryCreateBlameIgnoreRevsFile(commit) + if err != nil && !git.IsErrNotExist(err) { return nil, err - } - if ignoreRevsFileName != "" { + } else if err == nil { + rd.ignoreRevsFile = ignoreRevsFileName + rd.cleanupFuncs = append(rd.cleanupFuncs, ignoreRevsFileCleanup) // 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) @@ -158,40 +172,15 @@ func CreateBlameReader(ctx context.Context, objectFormat ObjectFormat, repoPath cmd.AddDynamicArguments(commit.ID.String()).AddDashesAndList(file) - done := make(chan error, 1) - reader, stdout, err := os.Pipe() - if err != nil { - return nil, err - } 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" - err := cmd.Run(ctx, &gitcmd.RunOpts{ - UseContextTimeout: true, - Dir: repoPath, - Stdout: stdout, - Stderr: &stderr, - }) - done <- err - _ = stdout.Close() - if err != nil { - log.Error("Error running git blame (dir: %v): %v, stderr: %v", repoPath, err, stderr.String()) - } + rd.done <- RunCmdWithStderr(ctx, repo, cmd) }() - bufferedReader := bufio.NewReader(reader) - return &BlameReader{ - output: stdout, - reader: reader, - bufferedReader: bufferedReader, - done: done, - ignoreRevsFile: ignoreRevsFileName, - objectFormat: objectFormat, - cleanupFuncs: []func(){ignoreRevsFileCleanup}, - }, nil + return rd, nil } -func tryCreateBlameIgnoreRevsFile(commit *Commit) (string, func(), error) { +func tryCreateBlameIgnoreRevsFile(commit *git.Commit) (string, func(), error) { entry, err := commit.GetTreeEntryByPath(".git-blame-ignore-revs") if err != nil { return "", nil, err diff --git a/modules/git/blame_sha256_test.go b/modules/gitrepo/blame_sha256_test.go similarity index 88% rename from modules/git/blame_sha256_test.go rename to modules/gitrepo/blame_sha256_test.go index c0a97bed3bd..e92931d596a 100644 --- a/modules/git/blame_sha256_test.go +++ b/modules/gitrepo/blame_sha256_test.go @@ -1,12 +1,13 @@ // Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -package git +package gitrepo import ( "context" "testing" + "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/setting" "github.com/stretchr/testify/assert" @@ -17,13 +18,14 @@ func TestReadingBlameOutputSha256(t *testing.T) { ctx, cancel := context.WithCancel(t.Context()) defer cancel() - if isGogit { + if git.DefaultFeatures().UsingGogit { t.Skip("Skipping test since gogit does not support sha256") return } t.Run("Without .git-blame-ignore-revs", func(t *testing.T) { - repo, err := OpenRepository(ctx, "./tests/repos/repo5_pulls_sha256") + storage := &mockRepository{path: "repo5_pulls_sha256"} + repo, err := OpenRepository(ctx, storage) assert.NoError(t, err) defer repo.Close() @@ -47,7 +49,7 @@ func TestReadingBlameOutputSha256(t *testing.T) { } for _, bypass := range []bool{false, true} { - blameReader, err := CreateBlameReader(ctx, Sha256ObjectFormat, "./tests/repos/repo5_pulls_sha256", commit, "README.md", bypass) + blameReader, err := CreateBlameReader(ctx, git.Sha256ObjectFormat, storage, commit, "README.md", bypass) assert.NoError(t, err) assert.NotNil(t, blameReader) defer blameReader.Close() @@ -68,7 +70,8 @@ func TestReadingBlameOutputSha256(t *testing.T) { }) t.Run("With .git-blame-ignore-revs", func(t *testing.T) { - repo, err := OpenRepository(ctx, "./tests/repos/repo6_blame_sha256") + storage := &mockRepository{path: "repo6_blame_sha256"} + repo, err := OpenRepository(ctx, storage) assert.NoError(t, err) defer repo.Close() @@ -131,7 +134,7 @@ func TestReadingBlameOutputSha256(t *testing.T) { for _, c := range cases { commit, err := repo.GetCommit(c.CommitID) assert.NoError(t, err) - blameReader, err := CreateBlameReader(ctx, objectFormat, "./tests/repos/repo6_blame_sha256", commit, "blame.txt", c.Bypass) + blameReader, err := CreateBlameReader(ctx, objectFormat, storage, commit, "blame.txt", c.Bypass) assert.NoError(t, err) assert.NotNil(t, blameReader) defer blameReader.Close() diff --git a/modules/git/blame_test.go b/modules/gitrepo/blame_test.go similarity index 89% rename from modules/git/blame_test.go rename to modules/gitrepo/blame_test.go index 809d6fbcf73..0307a5fd33c 100644 --- a/modules/git/blame_test.go +++ b/modules/gitrepo/blame_test.go @@ -1,12 +1,13 @@ // Copyright 2019 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -package git +package gitrepo import ( "context" "testing" + "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/setting" "github.com/stretchr/testify/assert" @@ -18,10 +19,10 @@ func TestReadingBlameOutput(t *testing.T) { defer cancel() t.Run("Without .git-blame-ignore-revs", func(t *testing.T) { - repo, err := OpenRepository(ctx, "./tests/repos/repo5_pulls") + storage := &mockRepository{path: "repo5_pulls"} + repo, err := OpenRepository(ctx, storage) assert.NoError(t, err) defer repo.Close() - commit, err := repo.GetCommit("f32b0a9dfd09a60f616f29158f772cedd89942d2") assert.NoError(t, err) @@ -42,7 +43,7 @@ func TestReadingBlameOutput(t *testing.T) { } for _, bypass := range []bool{false, true} { - blameReader, err := CreateBlameReader(ctx, Sha1ObjectFormat, "./tests/repos/repo5_pulls", commit, "README.md", bypass) + blameReader, err := CreateBlameReader(ctx, git.Sha1ObjectFormat, storage, commit, "README.md", bypass) assert.NoError(t, err) assert.NotNil(t, blameReader) defer blameReader.Close() @@ -63,7 +64,8 @@ func TestReadingBlameOutput(t *testing.T) { }) t.Run("With .git-blame-ignore-revs", func(t *testing.T) { - repo, err := OpenRepository(ctx, "./tests/repos/repo6_blame") + storage := &mockRepository{path: "repo6_blame"} + repo, err := OpenRepository(ctx, storage) assert.NoError(t, err) defer repo.Close() @@ -127,7 +129,7 @@ func TestReadingBlameOutput(t *testing.T) { commit, err := repo.GetCommit(c.CommitID) assert.NoError(t, err) - blameReader, err := CreateBlameReader(ctx, objectFormat, "./tests/repos/repo6_blame", commit, "blame.txt", c.Bypass) + blameReader, err := CreateBlameReader(ctx, objectFormat, storage, commit, "blame.txt", c.Bypass) assert.NoError(t, err) assert.NotNil(t, blameReader) defer blameReader.Close() diff --git a/modules/gitrepo/branch.go b/modules/gitrepo/branch.go index 4d571f583d0..4c40d1fba36 100644 --- a/modules/gitrepo/branch.go +++ b/modules/gitrepo/branch.go @@ -5,6 +5,8 @@ package gitrepo import ( "context" + "errors" + "strings" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git/gitcmd" @@ -34,23 +36,61 @@ func GetBranchCommitID(ctx context.Context, repo Repository, branch string) (str // SetDefaultBranch sets default branch of repository. func SetDefaultBranch(ctx context.Context, repo Repository, name string) error { - _, _, err := gitcmd.NewCommand("symbolic-ref", "HEAD"). - AddDynamicArguments(git.BranchPrefix+name). - RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath(repo)}) + _, _, err := RunCmdString(ctx, repo, gitcmd.NewCommand("symbolic-ref", "HEAD"). + AddDynamicArguments(git.BranchPrefix+name)) return err } // GetDefaultBranch gets default branch of repository. func GetDefaultBranch(ctx context.Context, repo Repository) (string, error) { - return git.GetDefaultBranch(ctx, repoPath(repo)) + stdout, _, err := RunCmdString(ctx, repo, gitcmd.NewCommand("symbolic-ref", "HEAD")) + if err != nil { + return "", err + } + stdout = strings.TrimSpace(stdout) + if !strings.HasPrefix(stdout, git.BranchPrefix) { + return "", errors.New("the HEAD is not a branch: " + stdout) + } + return strings.TrimPrefix(stdout, git.BranchPrefix), nil } // IsReferenceExist returns true if given reference exists in the repository. func IsReferenceExist(ctx context.Context, repo Repository, name string) bool { - return git.IsReferenceExist(ctx, repoPath(repo), name) + _, _, err := RunCmdString(ctx, repo, gitcmd.NewCommand("show-ref", "--verify").AddDashesAndList(name)) + return err == nil } // IsBranchExist returns true if given branch exists in the repository. func IsBranchExist(ctx context.Context, repo Repository, name string) bool { return IsReferenceExist(ctx, repo, git.BranchPrefix+name) } + +// DeleteBranch delete a branch by name on repository. +func DeleteBranch(ctx context.Context, repo Repository, name string, force bool) error { + cmd := gitcmd.NewCommand("branch") + + if force { + cmd.AddArguments("-D") + } else { + cmd.AddArguments("-d") + } + + cmd.AddDashesAndList(name) + _, _, err := RunCmdString(ctx, repo, cmd) + return err +} + +// CreateBranch create a new branch +func CreateBranch(ctx context.Context, repo Repository, branch, oldbranchOrCommit string) error { + cmd := gitcmd.NewCommand("branch") + cmd.AddDashesAndList(branch, oldbranchOrCommit) + + _, _, err := RunCmdString(ctx, repo, cmd) + return err +} + +// RenameBranch rename a branch +func RenameBranch(ctx context.Context, repo Repository, from, to string) error { + _, _, err := RunCmdString(ctx, repo, gitcmd.NewCommand("branch", "-m").AddDynamicArguments(from, to)) + return err +} diff --git a/modules/gitrepo/cat_file.go b/modules/gitrepo/cat_file.go new file mode 100644 index 00000000000..42ca23acdeb --- /dev/null +++ b/modules/gitrepo/cat_file.go @@ -0,0 +1,14 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "context" + + "code.gitea.io/gitea/modules/git" +) + +func NewBatch(ctx context.Context, repo Repository) (git.CatFileBatchCloser, error) { + return git.NewBatch(ctx, repoPath(repo)) +} diff --git a/modules/gitrepo/clone.go b/modules/gitrepo/clone.go new file mode 100644 index 00000000000..a0e4cc814cb --- /dev/null +++ b/modules/gitrepo/clone.go @@ -0,0 +1,24 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "context" + + "code.gitea.io/gitea/modules/git" +) + +// CloneExternalRepo clones an external repository to the managed repository. +func CloneExternalRepo(ctx context.Context, fromRemoteURL string, toRepo Repository, opts git.CloneRepoOptions) error { + return git.Clone(ctx, fromRemoteURL, repoPath(toRepo), opts) +} + +// CloneRepoToLocal clones a managed repository to a local path. +func CloneRepoToLocal(ctx context.Context, fromRepo Repository, toLocalPath string, opts git.CloneRepoOptions) error { + return git.Clone(ctx, repoPath(fromRepo), toLocalPath, opts) +} + +func Clone(ctx context.Context, fromRepo, toRepo Repository, opts git.CloneRepoOptions) error { + return git.Clone(ctx, repoPath(fromRepo), repoPath(toRepo), opts) +} diff --git a/modules/gitrepo/command.go b/modules/gitrepo/command.go new file mode 100644 index 00000000000..fd21b9a7254 --- /dev/null +++ b/modules/gitrepo/command.go @@ -0,0 +1,26 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "context" + + "code.gitea.io/gitea/modules/git/gitcmd" +) + +func RunCmd(ctx context.Context, repo Repository, cmd *gitcmd.Command) error { + return cmd.WithDir(repoPath(repo)).WithParentCallerInfo().Run(ctx) +} + +func RunCmdString(ctx context.Context, repo Repository, cmd *gitcmd.Command) (string, string, gitcmd.RunStdError) { + return cmd.WithDir(repoPath(repo)).WithParentCallerInfo().RunStdString(ctx) +} + +func RunCmdBytes(ctx context.Context, repo Repository, cmd *gitcmd.Command) ([]byte, []byte, gitcmd.RunStdError) { + return cmd.WithDir(repoPath(repo)).WithParentCallerInfo().RunStdBytes(ctx) +} + +func RunCmdWithStderr(ctx context.Context, repo Repository, cmd *gitcmd.Command) gitcmd.RunStdError { + return cmd.WithDir(repoPath(repo)).WithParentCallerInfo().RunWithStderr(ctx) +} diff --git a/modules/gitrepo/commit.go b/modules/gitrepo/commit.go new file mode 100644 index 00000000000..0ab17862fee --- /dev/null +++ b/modules/gitrepo/commit.go @@ -0,0 +1,112 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "context" + "strconv" + "strings" + "time" + + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/git/gitcmd" +) + +// CommitsCountOptions the options when counting commits +type CommitsCountOptions struct { + Not string + Revision []string + RelPath []string + Since string + Until string +} + +// CommitsCount returns number of total commits of until given revision. +func CommitsCount(ctx context.Context, repo Repository, opts CommitsCountOptions) (int64, error) { + cmd := gitcmd.NewCommand("rev-list", "--count") + + cmd.AddDynamicArguments(opts.Revision...) + + if opts.Not != "" { + cmd.AddOptionValues("--not", opts.Not) + } + + if len(opts.RelPath) > 0 { + cmd.AddDashesAndList(opts.RelPath...) + } + + stdout, _, err := cmd.WithDir(repoPath(repo)).RunStdString(ctx) + if err != nil { + return 0, err + } + + return strconv.ParseInt(strings.TrimSpace(stdout), 10, 64) +} + +// CommitsCountBetween return numbers of commits between two commits +func CommitsCountBetween(ctx context.Context, repo Repository, start, end string) (int64, error) { + count, err := CommitsCount(ctx, repo, CommitsCountOptions{ + Revision: []string{start + ".." + end}, + }) + + if err != nil && strings.Contains(err.Error(), "no merge base") { + // future versions of git >= 2.28 are likely to return an error if before and last have become unrelated. + // previously it would return the results of git rev-list before last so let's try that... + return CommitsCount(ctx, repo, CommitsCountOptions{ + Revision: []string{start, end}, + }) + } + + return count, err +} + +// FileCommitsCount return the number of files at a revision +func FileCommitsCount(ctx context.Context, repo Repository, revision, file string) (int64, error) { + return CommitsCount(ctx, repo, + CommitsCountOptions{ + Revision: []string{revision}, + RelPath: []string{file}, + }) +} + +// CommitsCountOfCommit returns number of total commits of until current revision. +func CommitsCountOfCommit(ctx context.Context, repo Repository, commitID string) (int64, error) { + return CommitsCount(ctx, repo, CommitsCountOptions{ + Revision: []string{commitID}, + }) +} + +// AllCommitsCount returns count of all commits in repository +func AllCommitsCount(ctx context.Context, repo Repository, hidePRRefs bool, files ...string) (int64, error) { + cmd := gitcmd.NewCommand("rev-list") + if hidePRRefs { + cmd.AddArguments("--exclude=" + git.PullPrefix + "*") + } + cmd.AddArguments("--all", "--count") + if len(files) > 0 { + cmd.AddDashesAndList(files...) + } + + stdout, _, err := RunCmdString(ctx, repo, cmd) + if err != nil { + return 0, err + } + + return strconv.ParseInt(strings.TrimSpace(stdout), 10, 64) +} + +func GetFullCommitID(ctx context.Context, repo Repository, shortID string) (string, error) { + return git.GetFullCommitID(ctx, repoPath(repo), shortID) +} + +// GetLatestCommitTime returns time for latest commit in repository (across all branches) +func GetLatestCommitTime(ctx context.Context, repo Repository) (time.Time, error) { + stdout, _, err := RunCmdString(ctx, repo, + gitcmd.NewCommand("for-each-ref", "--sort=-committerdate", git.BranchPrefix, "--count", "1", "--format=%(committerdate)")) + if err != nil { + return time.Time{}, err + } + commitTime := strings.TrimSpace(stdout) + return time.Parse("Mon Jan _2 15:04:05 2006 -0700", commitTime) +} diff --git a/modules/gitrepo/commit_file.go b/modules/gitrepo/commit_file.go new file mode 100644 index 00000000000..437b3b51ad1 --- /dev/null +++ b/modules/gitrepo/commit_file.go @@ -0,0 +1,88 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "bufio" + "context" + "io" + + "code.gitea.io/gitea/modules/git/gitcmd" + "code.gitea.io/gitea/modules/log" +) + +// CommitFileStatus represents status of files in a commit. +type CommitFileStatus struct { + Added []string + Removed []string + Modified []string +} + +// NewCommitFileStatus creates a CommitFileStatus +func NewCommitFileStatus() *CommitFileStatus { + return &CommitFileStatus{ + []string{}, []string{}, []string{}, + } +} + +func parseCommitFileStatus(fileStatus *CommitFileStatus, stdout io.Reader) { + rd := bufio.NewReader(stdout) + peek, err := rd.Peek(1) + if err != nil { + if err != io.EOF { + log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err) + } + return + } + if peek[0] == '\n' || peek[0] == '\x00' { + _, _ = rd.Discard(1) + } + for { + modifier, err := rd.ReadString('\x00') + if err != nil { + if err != io.EOF { + log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err) + } + return + } + file, err := rd.ReadString('\x00') + if err != nil { + if err != io.EOF { + log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err) + } + return + } + file = file[:len(file)-1] + switch modifier[0] { + case 'A': + fileStatus.Added = append(fileStatus.Added, file) + case 'D': + fileStatus.Removed = append(fileStatus.Removed, file) + case 'M': + fileStatus.Modified = append(fileStatus.Modified, file) + } + } +} + +// GetCommitFileStatus returns file status of commit in given repository. +func GetCommitFileStatus(ctx context.Context, repo Repository, commitID string) (*CommitFileStatus, error) { + cmd := gitcmd.NewCommand("log", "--name-status", "-m", "--pretty=format:", "--first-parent", "--no-renames", "-z", "-1") + stdout, stdoutClose := cmd.MakeStdoutPipe() + defer stdoutClose() + done := make(chan struct{}) + fileStatus := NewCommitFileStatus() + go func() { + parseCommitFileStatus(fileStatus, stdout) + close(done) + }() + err := cmd.AddDynamicArguments(commitID). + WithDir(repoPath(repo)). + RunWithStderr(ctx) + if err != nil { + return nil, err + } + + <-done + return fileStatus, nil +} diff --git a/modules/gitrepo/commit_file_test.go b/modules/gitrepo/commit_file_test.go new file mode 100644 index 00000000000..ec1018eebae --- /dev/null +++ b/modules/gitrepo/commit_file_test.go @@ -0,0 +1,175 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseCommitFileStatus(t *testing.T) { + type testcase struct { + output string + added []string + removed []string + modified []string + } + + kases := []testcase{ + { + // Merge commit + output: "MM\x00options/locale/locale_en-US.ini\x00", + modified: []string{ + "options/locale/locale_en-US.ini", + }, + added: []string{}, + removed: []string{}, + }, + { + // Spaces commit + output: "D\x00b\x00D\x00b b/b\x00A\x00b b/b b/b b/b\x00A\x00b b/b b/b b/b b/b\x00", + removed: []string{ + "b", + "b b/b", + }, + modified: []string{}, + added: []string{ + "b b/b b/b b/b", + "b b/b b/b b/b b/b", + }, + }, + { + // larger commit + output: "M\x00go.mod\x00M\x00go.sum\x00M\x00modules/ssh/ssh.go\x00M\x00vendor/github.com/gliderlabs/ssh/circle.yml\x00M\x00vendor/github.com/gliderlabs/ssh/context.go\x00A\x00vendor/github.com/gliderlabs/ssh/go.mod\x00A\x00vendor/github.com/gliderlabs/ssh/go.sum\x00M\x00vendor/github.com/gliderlabs/ssh/server.go\x00M\x00vendor/github.com/gliderlabs/ssh/session.go\x00M\x00vendor/github.com/gliderlabs/ssh/ssh.go\x00M\x00vendor/golang.org/x/sys/unix/mkerrors.sh\x00M\x00vendor/golang.org/x/sys/unix/syscall_darwin.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_darwin_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_darwin_arm64.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_freebsd_386.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_freebsd_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_freebsd_arm.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_freebsd_arm64.go\x00M\x00vendor/golang.org/x/sys/unix/zerrors_linux.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_darwin_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_darwin_arm64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_dragonfly_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_freebsd_386.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_freebsd_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_freebsd_arm.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_freebsd_arm64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_netbsd_386.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_netbsd_amd64.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_netbsd_arm.go\x00M\x00vendor/golang.org/x/sys/unix/ztypes_netbsd_arm64.go\x00M\x00vendor/modules.txt\x00", + modified: []string{ + "go.mod", + "go.sum", + "modules/ssh/ssh.go", + "vendor/github.com/gliderlabs/ssh/circle.yml", + "vendor/github.com/gliderlabs/ssh/context.go", + "vendor/github.com/gliderlabs/ssh/server.go", + "vendor/github.com/gliderlabs/ssh/session.go", + "vendor/github.com/gliderlabs/ssh/ssh.go", + "vendor/golang.org/x/sys/unix/mkerrors.sh", + "vendor/golang.org/x/sys/unix/syscall_darwin.go", + "vendor/golang.org/x/sys/unix/zerrors_darwin_amd64.go", + "vendor/golang.org/x/sys/unix/zerrors_darwin_arm64.go", + "vendor/golang.org/x/sys/unix/zerrors_freebsd_386.go", + "vendor/golang.org/x/sys/unix/zerrors_freebsd_amd64.go", + "vendor/golang.org/x/sys/unix/zerrors_freebsd_arm.go", + "vendor/golang.org/x/sys/unix/zerrors_freebsd_arm64.go", + "vendor/golang.org/x/sys/unix/zerrors_linux.go", + "vendor/golang.org/x/sys/unix/ztypes_darwin_amd64.go", + "vendor/golang.org/x/sys/unix/ztypes_darwin_arm64.go", + "vendor/golang.org/x/sys/unix/ztypes_dragonfly_amd64.go", + "vendor/golang.org/x/sys/unix/ztypes_freebsd_386.go", + "vendor/golang.org/x/sys/unix/ztypes_freebsd_amd64.go", + "vendor/golang.org/x/sys/unix/ztypes_freebsd_arm.go", + "vendor/golang.org/x/sys/unix/ztypes_freebsd_arm64.go", + "vendor/golang.org/x/sys/unix/ztypes_netbsd_386.go", + "vendor/golang.org/x/sys/unix/ztypes_netbsd_amd64.go", + "vendor/golang.org/x/sys/unix/ztypes_netbsd_arm.go", + "vendor/golang.org/x/sys/unix/ztypes_netbsd_arm64.go", + "vendor/modules.txt", + }, + added: []string{ + "vendor/github.com/gliderlabs/ssh/go.mod", + "vendor/github.com/gliderlabs/ssh/go.sum", + }, + removed: []string{}, + }, + { + // git 1.7.2 adds an unnecessary \x00 on merge commit + output: "\x00MM\x00options/locale/locale_en-US.ini\x00", + modified: []string{ + "options/locale/locale_en-US.ini", + }, + added: []string{}, + removed: []string{}, + }, + { + // git 1.7.2 adds an unnecessary \n on normal commit + output: "\nD\x00b\x00D\x00b b/b\x00A\x00b b/b b/b b/b\x00A\x00b b/b b/b b/b b/b\x00", + removed: []string{ + "b", + "b b/b", + }, + modified: []string{}, + added: []string{ + "b b/b b/b b/b", + "b b/b b/b b/b b/b", + }, + }, + } + + for _, kase := range kases { + fileStatus := NewCommitFileStatus() + parseCommitFileStatus(fileStatus, strings.NewReader(kase.output)) + + assert.Equal(t, kase.added, fileStatus.Added) + assert.Equal(t, kase.removed, fileStatus.Removed) + assert.Equal(t, kase.modified, fileStatus.Modified) + } +} + +func TestGetCommitFileStatusMerges(t *testing.T) { + bareRepo6 := &mockRepository{path: "repo6_merge"} + + commitFileStatus, err := GetCommitFileStatus(t.Context(), bareRepo6, "022f4ce6214973e018f02bf363bf8a2e3691f699") + assert.NoError(t, err) + + expected := CommitFileStatus{ + []string{ + "add_file.txt", + }, + []string{ + "to_remove.txt", + }, + []string{ + "to_modify.txt", + }, + } + + assert.Equal(t, expected.Added, commitFileStatus.Added) + assert.Equal(t, expected.Removed, commitFileStatus.Removed) + assert.Equal(t, expected.Modified, commitFileStatus.Modified) +} + +func TestGetCommitFileStatusMergesSha256(t *testing.T) { + bareRepo6Sha256 := &mockRepository{path: "repo6_merge_sha256"} + + commitFileStatus, err := GetCommitFileStatus(t.Context(), bareRepo6Sha256, "d2e5609f630dd8db500f5298d05d16def282412e3e66ed68cc7d0833b29129a1") + assert.NoError(t, err) + + expected := CommitFileStatus{ + []string{ + "add_file.txt", + }, + []string{}, + []string{ + "to_modify.txt", + }, + } + + assert.Equal(t, expected.Added, commitFileStatus.Added) + assert.Equal(t, expected.Removed, commitFileStatus.Removed) + assert.Equal(t, expected.Modified, commitFileStatus.Modified) + + expected = CommitFileStatus{ + []string{}, + []string{ + "to_remove.txt", + }, + []string{}, + } + + commitFileStatus, err = GetCommitFileStatus(t.Context(), bareRepo6Sha256, "da1ded40dc8e5b7c564171f4bf2fc8370487decfb1cb6a99ef28f3ed73d09172") + assert.NoError(t, err) + + assert.Equal(t, expected.Added, commitFileStatus.Added) + assert.Equal(t, expected.Removed, commitFileStatus.Removed) + assert.Equal(t, expected.Modified, commitFileStatus.Modified) +} diff --git a/modules/gitrepo/commit_test.go b/modules/gitrepo/commit_test.go new file mode 100644 index 00000000000..05cedc39efd --- /dev/null +++ b/modules/gitrepo/commit_test.go @@ -0,0 +1,45 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCommitsCount(t *testing.T) { + bareRepo1 := &mockRepository{path: "repo1_bare"} + + commitsCount, err := CommitsCount(t.Context(), bareRepo1, + CommitsCountOptions{ + Revision: []string{"8006ff9adbf0cb94da7dad9e537e53817f9fa5c0"}, + }) + + assert.NoError(t, err) + assert.Equal(t, int64(3), commitsCount) +} + +func TestCommitsCountWithoutBase(t *testing.T) { + bareRepo1 := &mockRepository{path: "repo1_bare"} + + commitsCount, err := CommitsCount(t.Context(), bareRepo1, + CommitsCountOptions{ + Not: "master", + Revision: []string{"branch1"}, + }) + + assert.NoError(t, err) + assert.Equal(t, int64(2), commitsCount) +} + +func TestGetLatestCommitTime(t *testing.T) { + bareRepo1 := &mockRepository{path: "repo1_bare"} + lct, err := GetLatestCommitTime(t.Context(), bareRepo1) + assert.NoError(t, err) + // Time is Sun Nov 13 16:40:14 2022 +0100 + // which is the time of commit + // ce064814f4a0d337b333e646ece456cd39fab612 (refs/heads/master) + assert.EqualValues(t, 1668354014, lct.Unix()) +} diff --git a/modules/gitrepo/commitgraph.go b/modules/gitrepo/commitgraph.go new file mode 100644 index 00000000000..7310e167f69 --- /dev/null +++ b/modules/gitrepo/commitgraph.go @@ -0,0 +1,14 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "context" + + "code.gitea.io/gitea/modules/git" +) + +func WriteCommitGraph(ctx context.Context, repo Repository) error { + return git.WriteCommitGraph(ctx, repoPath(repo)) +} diff --git a/modules/gitrepo/compare.go b/modules/gitrepo/compare.go new file mode 100644 index 00000000000..06cf880d993 --- /dev/null +++ b/modules/gitrepo/compare.go @@ -0,0 +1,44 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "context" + "fmt" + "strconv" + "strings" + + "code.gitea.io/gitea/modules/git/gitcmd" +) + +// DivergeObject represents commit count diverging commits +type DivergeObject struct { + Ahead int + Behind int +} + +// GetDivergingCommits returns the number of commits a targetBranch is ahead or behind a baseBranch +func GetDivergingCommits(ctx context.Context, repo Repository, baseBranch, targetBranch string) (*DivergeObject, error) { + cmd := gitcmd.NewCommand("rev-list", "--count", "--left-right"). + AddDynamicArguments(baseBranch + "..." + targetBranch).AddArguments("--") + stdout, _, err1 := RunCmdString(ctx, repo, cmd) + if err1 != nil { + return nil, err1 + } + + left, right, found := strings.Cut(strings.Trim(stdout, "\n"), "\t") + if !found { + return nil, fmt.Errorf("git rev-list output is missing a tab: %q", stdout) + } + + behind, err := strconv.Atoi(left) + if err != nil { + return nil, err + } + ahead, err := strconv.Atoi(right) + if err != nil { + return nil, err + } + return &DivergeObject{Ahead: ahead, Behind: behind}, nil +} diff --git a/modules/gitrepo/compare_test.go b/modules/gitrepo/compare_test.go new file mode 100644 index 00000000000..f8661d94121 --- /dev/null +++ b/modules/gitrepo/compare_test.go @@ -0,0 +1,42 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +type mockRepository struct { + path string +} + +func (r *mockRepository) RelativePath() string { + return r.path +} + +func TestRepoGetDivergingCommits(t *testing.T) { + repo := &mockRepository{path: "repo1_bare"} + do, err := GetDivergingCommits(t.Context(), repo, "master", "branch2") + assert.NoError(t, err) + assert.Equal(t, &DivergeObject{ + Ahead: 1, + Behind: 5, + }, do) + + do, err = GetDivergingCommits(t.Context(), repo, "master", "master") + assert.NoError(t, err) + assert.Equal(t, &DivergeObject{ + Ahead: 0, + Behind: 0, + }, do) + + do, err = GetDivergingCommits(t.Context(), repo, "master", "test") + assert.NoError(t, err) + assert.Equal(t, &DivergeObject{ + Ahead: 0, + Behind: 2, + }, do) +} diff --git a/modules/gitrepo/config.go b/modules/gitrepo/config.go index c028c6afe8f..9be3ef94aeb 100644 --- a/modules/gitrepo/config.go +++ b/modules/gitrepo/config.go @@ -12,9 +12,8 @@ import ( ) func GitConfigGet(ctx context.Context, repo Repository, key string) (string, error) { - result, _, err := gitcmd.NewCommand("config", "--get"). - AddDynamicArguments(key). - RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath(repo)}) + result, _, err := RunCmdString(ctx, repo, gitcmd.NewCommand("config", "--get"). + AddDynamicArguments(key)) if err != nil { return "", err } @@ -28,9 +27,8 @@ func getRepoConfigLockKey(repoStoragePath string) string { // GitConfigAdd add a git configuration key to a specific value for the given repository. func GitConfigAdd(ctx context.Context, repo Repository, key, value string) error { return globallock.LockAndDo(ctx, getRepoConfigLockKey(repo.RelativePath()), func(ctx context.Context) error { - _, _, err := gitcmd.NewCommand("config", "--add"). - AddDynamicArguments(key, value). - RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath(repo)}) + _, _, err := RunCmdString(ctx, repo, gitcmd.NewCommand("config", "--add"). + AddDynamicArguments(key, value)) return err }) } @@ -40,9 +38,8 @@ func GitConfigAdd(ctx context.Context, repo Repository, key, value string) error // If the key exists, it will be updated to the new value. func GitConfigSet(ctx context.Context, repo Repository, key, value string) error { return globallock.LockAndDo(ctx, getRepoConfigLockKey(repo.RelativePath()), func(ctx context.Context) error { - _, _, err := gitcmd.NewCommand("config"). - AddDynamicArguments(key, value). - RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath(repo)}) + _, _, err := RunCmdString(ctx, repo, gitcmd.NewCommand("config"). + AddDynamicArguments(key, value)) return err }) } diff --git a/modules/gitrepo/diff.go b/modules/gitrepo/diff.go new file mode 100644 index 00000000000..0092cf0bb8c --- /dev/null +++ b/modules/gitrepo/diff.go @@ -0,0 +1,71 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "context" + "fmt" + "io" + "regexp" + "strconv" + + "code.gitea.io/gitea/modules/git/gitcmd" +) + +// GetDiffShortStatByCmdArgs counts number of changed files, number of additions and deletions +// TODO: it can be merged with another "GetDiffShortStat" in the future +func GetDiffShortStatByCmdArgs(ctx context.Context, repo Repository, trustedArgs gitcmd.TrustedCmdArgs, dynamicArgs ...string) (numFiles, totalAdditions, totalDeletions int, err error) { + // Now if we call: + // $ git diff --shortstat 1ebb35b98889ff77299f24d82da426b434b0cca0...788b8b1440462d477f45b0088875 + // we get: + // " 9902 files changed, 2034198 insertions(+), 298800 deletions(-)\n" + cmd := gitcmd.NewCommand("diff", "--shortstat").AddArguments(trustedArgs...).AddDynamicArguments(dynamicArgs...) + stdout, _, err := RunCmdString(ctx, repo, cmd) + if err != nil { + return 0, 0, 0, err + } + + return parseDiffStat(stdout) +} + +var shortStatFormat = regexp.MustCompile( + `\s*(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?`) + +func parseDiffStat(stdout string) (numFiles, totalAdditions, totalDeletions int, err error) { + if len(stdout) == 0 || stdout == "\n" { + return 0, 0, 0, nil + } + groups := shortStatFormat.FindStringSubmatch(stdout) + if len(groups) != 4 { + return 0, 0, 0, fmt.Errorf("unable to parse shortstat: %s groups: %s", stdout, groups) + } + + numFiles, err = strconv.Atoi(groups[1]) + if err != nil { + return 0, 0, 0, fmt.Errorf("unable to parse shortstat: %s. Error parsing NumFiles %w", stdout, err) + } + + if len(groups[2]) != 0 { + totalAdditions, err = strconv.Atoi(groups[2]) + if err != nil { + return 0, 0, 0, fmt.Errorf("unable to parse shortstat: %s. Error parsing NumAdditions %w", stdout, err) + } + } + + if len(groups[3]) != 0 { + totalDeletions, err = strconv.Atoi(groups[3]) + if err != nil { + return 0, 0, 0, fmt.Errorf("unable to parse shortstat: %s. Error parsing NumDeletions %w", stdout, err) + } + } + return numFiles, totalAdditions, totalDeletions, err +} + +// GetReverseRawDiff dumps the reverse diff results of repository in given commit ID to io.Writer. +func GetReverseRawDiff(ctx context.Context, repo Repository, commitID string, writer io.Writer) error { + return RunCmdWithStderr(ctx, repo, gitcmd.NewCommand("show", "--pretty=format:revert %H%n", "-R"). + AddDynamicArguments(commitID). + WithStdoutCopy(writer), + ) +} diff --git a/modules/gitrepo/fetch.go b/modules/gitrepo/fetch.go new file mode 100644 index 00000000000..0474d6111e6 --- /dev/null +++ b/modules/gitrepo/fetch.go @@ -0,0 +1,28 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "context" + + "code.gitea.io/gitea/modules/git/gitcmd" + "code.gitea.io/gitea/modules/globallock" +) + +// FetchRemoteCommit fetches a specific commit and its related objects from a remote +// repository into the managed repository. +// +// If no reference (branch, tag, or other ref) points to the fetched commit, it will +// be treated as unreachable and cleaned up by `git gc` after the default prune +// expiration period (2 weeks). Ref: https://www.kernel.org/pub/software/scm/git/docs/git-gc.html +// +// This behavior is sufficient for temporary operations, such as determining the +// merge base between commits. +func FetchRemoteCommit(ctx context.Context, repo, remoteRepo Repository, commitID string) error { + return globallock.LockAndDo(ctx, getRepoWriteLockKey(repo.RelativePath()), func(ctx context.Context) error { + return RunCmd(ctx, repo, gitcmd.NewCommand("fetch", "--no-tags"). + AddDynamicArguments(repoPath(remoteRepo)). + AddDynamicArguments(commitID)) + }) +} diff --git a/modules/gitrepo/fsck.go b/modules/gitrepo/fsck.go index ffccff28a9d..f74ca3b46a4 100644 --- a/modules/gitrepo/fsck.go +++ b/modules/gitrepo/fsck.go @@ -12,5 +12,5 @@ import ( // Fsck verifies the connectivity and validity of the objects in the database func Fsck(ctx context.Context, repo Repository, timeout time.Duration, args gitcmd.TrustedCmdArgs) error { - return gitcmd.NewCommand("fsck").AddArguments(args...).Run(ctx, &gitcmd.RunOpts{Timeout: timeout, Dir: repoPath(repo)}) + return RunCmd(ctx, repo, gitcmd.NewCommand("fsck").AddArguments(args...).WithTimeout(timeout)) } diff --git a/modules/gitrepo/gitrepo.go b/modules/gitrepo/gitrepo.go index fad8f70c4cb..535d72ed98a 100644 --- a/modules/gitrepo/gitrepo.go +++ b/modules/gitrepo/gitrepo.go @@ -7,9 +7,12 @@ import ( "context" "fmt" "io" + "io/fs" + "os" "path/filepath" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/git/gitcmd" "code.gitea.io/gitea/modules/reqctx" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" @@ -20,9 +23,9 @@ type Repository interface { RelativePath() string // We don't assume how the directory structure of the repository is, so we only need the relative path } -// RelativePath should be an unix style path like username/reponame.git -// This method should change it according to the current OS. -func repoPath(repo Repository) string { +// repoPath resolves the Repository.RelativePath (which is a unix-style path like "username/reponame.git") +// to a local filesystem path according to setting.RepoRootPath +var repoPath = func(repo Repository) string { return filepath.Join(setting.RepoRootPath, filepath.FromSlash(repo.RelativePath())) } @@ -77,7 +80,12 @@ func DeleteRepository(ctx context.Context, repo Repository) error { // RenameRepository renames a repository's name on disk func RenameRepository(ctx context.Context, repo, newRepo Repository) error { - if err := util.Rename(repoPath(repo), repoPath(newRepo)); err != nil { + dstDir := repoPath(newRepo) + if err := os.MkdirAll(filepath.Dir(dstDir), os.ModePerm); err != nil { + return fmt.Errorf("Failed to create dir %s: %w", filepath.Dir(dstDir), err) + } + + if err := util.Rename(repoPath(repo), dstDir); err != nil { return fmt.Errorf("rename repository directory: %w", err) } return nil @@ -86,3 +94,35 @@ func RenameRepository(ctx context.Context, repo, newRepo Repository) error { func InitRepository(ctx context.Context, repo Repository, objectFormatName string) error { return git.InitRepository(ctx, repoPath(repo), true, objectFormatName) } + +func UpdateServerInfo(ctx context.Context, repo Repository) error { + _, _, err := RunCmdBytes(ctx, repo, gitcmd.NewCommand("update-server-info")) + return err +} + +func GetRepoFS(repo Repository) fs.FS { + return os.DirFS(repoPath(repo)) +} + +func IsRepoFileExist(ctx context.Context, repo Repository, relativeFilePath string) (bool, error) { + absoluteFilePath := filepath.Join(repoPath(repo), relativeFilePath) + return util.IsExist(absoluteFilePath) +} + +func IsRepoDirExist(ctx context.Context, repo Repository, relativeDirPath string) (bool, error) { + absoluteDirPath := filepath.Join(repoPath(repo), relativeDirPath) + return util.IsDir(absoluteDirPath) +} + +func RemoveRepoFileOrDir(ctx context.Context, repo Repository, relativeFileOrDirPath string) error { + absoluteFilePath := filepath.Join(repoPath(repo), relativeFileOrDirPath) + return util.Remove(absoluteFilePath) +} + +func CreateRepoFile(ctx context.Context, repo Repository, relativeFilePath string) (io.WriteCloser, error) { + absoluteFilePath := filepath.Join(repoPath(repo), relativeFilePath) + if err := os.MkdirAll(filepath.Dir(absoluteFilePath), os.ModePerm); err != nil { + return nil, err + } + return os.Create(absoluteFilePath) +} diff --git a/modules/gitrepo/main_test.go b/modules/gitrepo/main_test.go new file mode 100644 index 00000000000..08afdffcc6a --- /dev/null +++ b/modules/gitrepo/main_test.go @@ -0,0 +1,25 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "path/filepath" + "testing" + + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/setting" +) + +func TestMain(m *testing.M) { + // resolve repository path relative to the test directory + setting.SetupGiteaTestEnv() + giteaRoot := setting.GetGiteaTestSourceRoot() + repoPath = func(repo Repository) string { + if filepath.IsAbs(repo.RelativePath()) { + return repo.RelativePath() // for testing purpose only + } + return filepath.Join(giteaRoot, "modules/git/tests/repos", repo.RelativePath()) + } + git.RunGitTests(m) +} diff --git a/modules/gitrepo/merge.go b/modules/gitrepo/merge.go new file mode 100644 index 00000000000..8d58e21c8db --- /dev/null +++ b/modules/gitrepo/merge.go @@ -0,0 +1,22 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "context" + "fmt" + "strings" + + "code.gitea.io/gitea/modules/git/gitcmd" +) + +// MergeBase checks and returns merge base of two commits. +func MergeBase(ctx context.Context, repo Repository, baseCommitID, headCommitID string) (string, error) { + mergeBase, _, err := RunCmdString(ctx, repo, gitcmd.NewCommand("merge-base"). + AddDashesAndList(baseCommitID, headCommitID)) + if err != nil { + return "", fmt.Errorf("get merge-base of %s and %s failed: %w", baseCommitID, headCommitID, err) + } + return strings.TrimSpace(mergeBase), nil +} diff --git a/modules/gitrepo/merge_tree.go b/modules/gitrepo/merge_tree.go new file mode 100644 index 00000000000..6151b1179fc --- /dev/null +++ b/modules/gitrepo/merge_tree.go @@ -0,0 +1,59 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "bufio" + "context" + "fmt" + + "code.gitea.io/gitea/modules/git/gitcmd" + "code.gitea.io/gitea/modules/util" +) + +const MaxConflictedDetectFiles = 10 + +// MergeTree performs a merge between two commits (baseRef and headRef) with an optional merge base. +// It returns the resulting tree hash, a list of conflicted files (if any), and an error if the operation fails. +// If there are no conflicts, the list of conflicted files will be nil. +func MergeTree(ctx context.Context, repo Repository, baseRef, headRef, mergeBase string) (treeID string, isErrHasConflicts bool, conflictFiles []string, _ error) { + cmd := gitcmd.NewCommand("merge-tree", "--write-tree", "-z", "--name-only", "--no-messages"). + AddOptionFormat("--merge-base=%s", mergeBase). + AddDynamicArguments(baseRef, headRef) + + stdout, stdoutClose := cmd.MakeStdoutPipe() + defer stdoutClose() + cmd.WithPipelineFunc(func(ctx gitcmd.Context) error { + // https://git-scm.com/docs/git-merge-tree/2.38.0#OUTPUT + // For a conflicted merge, the output is: + // NUL + // NUL + // NUL + // ... + scanner := bufio.NewScanner(stdout) + scanner.Split(util.BufioScannerSplit(0)) + for scanner.Scan() { + line := scanner.Text() + if treeID == "" { // first line is tree ID + treeID = line + continue + } + conflictFiles = append(conflictFiles, line) + if len(conflictFiles) >= MaxConflictedDetectFiles { + break + } + } + return scanner.Err() + }) + + err := RunCmdWithStderr(ctx, repo, cmd) + // For a successful, non-conflicted merge, the exit status is 0. When the merge has conflicts, the exit status is 1. + // A merge can have conflicts without having individual files conflict + // https://git-scm.com/docs/git-merge-tree/2.38.0#_mistakes_to_avoid + isErrHasConflicts = gitcmd.IsErrorExitCode(err, 1) + if err == nil || isErrHasConflicts { + return treeID, isErrHasConflicts, conflictFiles, nil + } + return "", false, nil, fmt.Errorf("run merge-tree failed: %w", err) +} diff --git a/modules/gitrepo/merge_tree_test.go b/modules/gitrepo/merge_tree_test.go new file mode 100644 index 00000000000..9327a0c3d80 --- /dev/null +++ b/modules/gitrepo/merge_tree_test.go @@ -0,0 +1,82 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "path/filepath" + "testing" + + "code.gitea.io/gitea/modules/git/gitcmd" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func prepareRepoDirRenameConflict(t *testing.T) string { + repoDir := filepath.Join(t.TempDir(), "repo-dir-rename-conflict.git") + require.NoError(t, gitcmd.NewCommand("init", "--bare").AddDynamicArguments(repoDir).Run(t.Context())) + stdin := `blob +mark :1 +data 2 +b + +blob +mark :2 +data 2 +c + +reset refs/heads/master +commit refs/heads/master +mark :3 +author test 1769202331 -0800 +committer test 1769202331 -0800 +data 2 +O +M 100644 :1 z/b +M 100644 :2 z/c + +commit refs/heads/split +mark :4 +author test 1769202336 -0800 +committer test 1769202336 -0800 +data 2 +A +from :3 +M 100644 :2 w/c +M 100644 :1 y/b +D z/b +D z/c + +blob +mark :5 +data 2 +d + +commit refs/heads/add +mark :6 +author test 1769202342 -0800 +committer test 1769202342 -0800 +data 2 +B +from :3 +M 100644 :5 z/d +` + require.NoError(t, gitcmd.NewCommand("fast-import").WithDir(repoDir).WithStdinBytes([]byte(stdin)).Run(t.Context())) + return repoDir +} + +func TestMergeTreeDirectoryRenameConflictWithoutFiles(t *testing.T) { + repoDir := prepareRepoDirRenameConflict(t) + require.DirExists(t, repoDir) + repo := &mockRepository{path: repoDir} + + mergeBase, err := MergeBase(t.Context(), repo, "add", "split") + require.NoError(t, err) + + treeID, conflicted, conflictedFiles, err := MergeTree(t.Context(), repo, "add", "split", mergeBase) + require.NoError(t, err) + assert.True(t, conflicted) + assert.Empty(t, conflictedFiles) + assert.Equal(t, "5e3dd4cfc5b11e278a35b2daa83b7274175e3ab1", treeID) +} diff --git a/modules/gitrepo/push.go b/modules/gitrepo/push.go new file mode 100644 index 00000000000..920c317f796 --- /dev/null +++ b/modules/gitrepo/push.go @@ -0,0 +1,27 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "context" + + "code.gitea.io/gitea/modules/git" +) + +// PushToExternal pushes a managed repository to an external remote. +func PushToExternal(ctx context.Context, repo Repository, opts git.PushOptions) error { + return git.Push(ctx, repoPath(repo), opts) +} + +// Push pushes from one managed repository to another managed repository. +func Push(ctx context.Context, fromRepo, toRepo Repository, opts git.PushOptions) error { + opts.Remote = repoPath(toRepo) + return git.Push(ctx, repoPath(fromRepo), opts) +} + +// PushFromLocal pushes from a local path to a managed repository. +func PushFromLocal(ctx context.Context, fromLocalPath string, toRepo Repository, opts git.PushOptions) error { + opts.Remote = repoPath(toRepo) + return git.Push(ctx, fromLocalPath, opts) +} diff --git a/modules/gitrepo/ref.go b/modules/gitrepo/ref.go index babef8b65f0..52125283268 100644 --- a/modules/gitrepo/ref.go +++ b/modules/gitrepo/ref.go @@ -10,12 +10,10 @@ import ( ) func UpdateRef(ctx context.Context, repo Repository, refName, newCommitID string) error { - _, _, err := gitcmd.NewCommand("update-ref").AddDynamicArguments(refName, newCommitID).RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath(repo)}) - return err + return RunCmd(ctx, repo, gitcmd.NewCommand("update-ref").AddDynamicArguments(refName, newCommitID)) } func RemoveRef(ctx context.Context, repo Repository, refName string) error { - _, _, err := gitcmd.NewCommand("update-ref", "--no-deref", "-d"). - AddDynamicArguments(refName).RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath(repo)}) - return err + return RunCmd(ctx, repo, gitcmd.NewCommand("update-ref", "--no-deref", "-d"). + AddDynamicArguments(refName)) } diff --git a/modules/gitrepo/remote.go b/modules/gitrepo/remote.go index 20d3cd21da3..3cbc34eedb0 100644 --- a/modules/gitrepo/remote.go +++ b/modules/gitrepo/remote.go @@ -6,8 +6,6 @@ package gitrepo import ( "context" "errors" - "io" - "time" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git/gitcmd" @@ -36,9 +34,7 @@ func GitRemoteAdd(ctx context.Context, repo Repository, remoteName, remoteURL st return errors.New("unknown remote option: " + string(options[0])) } } - _, _, err := cmd. - AddDynamicArguments(remoteName, remoteURL). - RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath(repo)}) + _, _, err := RunCmdString(ctx, repo, cmd.AddDynamicArguments(remoteName, remoteURL)) return err }) } @@ -46,7 +42,7 @@ func GitRemoteAdd(ctx context.Context, repo Repository, remoteName, remoteURL st func GitRemoteRemove(ctx context.Context, repo Repository, remoteName string) error { return globallock.LockAndDo(ctx, getRepoConfigLockKey(repo.RelativePath()), func(ctx context.Context) error { cmd := gitcmd.NewCommand("remote", "rm").AddDynamicArguments(remoteName) - _, _, err := cmd.RunStdString(ctx, &gitcmd.RunOpts{Dir: repoPath(repo)}) + _, _, err := RunCmdString(ctx, repo, cmd) return err }) } @@ -62,25 +58,3 @@ func GitRemoteGetURL(ctx context.Context, repo Repository, remoteName string) (* } return giturl.ParseGitURL(addr) } - -// GitRemotePrune prunes the remote branches that no longer exist in the remote repository. -func GitRemotePrune(ctx context.Context, repo Repository, remoteName string, timeout time.Duration, stdout, stderr io.Writer) error { - return gitcmd.NewCommand("remote", "prune").AddDynamicArguments(remoteName). - Run(ctx, &gitcmd.RunOpts{ - Timeout: timeout, - Dir: repoPath(repo), - Stdout: stdout, - Stderr: stderr, - }) -} - -// GitRemoteUpdatePrune updates the remote branches and prunes the ones that no longer exist in the remote repository. -func GitRemoteUpdatePrune(ctx context.Context, repo Repository, remoteName string, timeout time.Duration, stdout, stderr io.Writer) error { - return gitcmd.NewCommand("remote", "update", "--prune").AddDynamicArguments(remoteName). - Run(ctx, &gitcmd.RunOpts{ - Timeout: timeout, - Dir: repoPath(repo), - Stdout: stdout, - Stderr: stderr, - }) -} diff --git a/modules/gitrepo/repo_lock.go b/modules/gitrepo/repo_lock.go new file mode 100644 index 00000000000..2eb89ce8079 --- /dev/null +++ b/modules/gitrepo/repo_lock.go @@ -0,0 +1,10 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +// getRepoWriteLockKey returns the global lock key for write operations on the repository. +// Parallel write operations on the same git repository should be avoided to prevent data corruption. +func getRepoWriteLockKey(repoStoragePath string) string { + return "repo-write:" + repoStoragePath +} diff --git a/modules/gitrepo/signing.go b/modules/gitrepo/signing.go new file mode 100644 index 00000000000..2f77758d8c8 --- /dev/null +++ b/modules/gitrepo/signing.go @@ -0,0 +1,14 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "context" + + "code.gitea.io/gitea/modules/git" +) + +func GetSigningKey(ctx context.Context) (*git.SigningKey, *git.Signature) { + return git.GetSigningKey(ctx) +} diff --git a/modules/gitrepo/size.go b/modules/gitrepo/size.go new file mode 100644 index 00000000000..7524bb25422 --- /dev/null +++ b/modules/gitrepo/size.go @@ -0,0 +1,37 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitrepo + +import ( + "os" + "path/filepath" +) + +const notRegularFileMode = os.ModeSymlink | os.ModeNamedPipe | os.ModeSocket | os.ModeDevice | os.ModeCharDevice | os.ModeIrregular + +// CalcRepositorySize returns the disk consumption for a given path +func CalcRepositorySize(repo Repository) (int64, error) { + var size int64 + err := filepath.WalkDir(repoPath(repo), func(_ string, entry os.DirEntry, err error) error { + if os.IsNotExist(err) { // ignore the error because some files (like temp/lock file) may be deleted during traversing. + return nil + } else if err != nil { + return err + } + if entry.IsDir() { + return nil + } + info, err := entry.Info() + if os.IsNotExist(err) { // ignore the error as above + return nil + } else if err != nil { + return err + } + if (info.Mode() & notRegularFileMode) == 0 { + size += info.Size() + } + return nil + }) + return size, err +} diff --git a/modules/graceful/manager.go b/modules/graceful/manager.go index ee1872b9996..51bd5a23346 100644 --- a/modules/graceful/manager.go +++ b/modules/graceful/manager.go @@ -74,12 +74,6 @@ func (g *Manager) RunWithCancel(rc RunCanceler) { g.RunAtShutdown(context.Background(), rc.Cancel) g.runningServerWaitGroup.Add(1) defer g.runningServerWaitGroup.Done() - defer func() { - if err := recover(); err != nil { - log.Critical("PANIC during RunWithCancel: %v\nStacktrace: %s", err, log.Stack(2)) - g.doShutdown() - } - }() rc.Run() } @@ -89,12 +83,6 @@ func (g *Manager) RunWithCancel(rc RunCanceler) { func (g *Manager) RunWithShutdownContext(run func(context.Context)) { g.runningServerWaitGroup.Add(1) defer g.runningServerWaitGroup.Done() - defer func() { - if err := recover(); err != nil { - log.Critical("PANIC during RunWithShutdownContext: %v\nStacktrace: %s", err, log.Stack(2)) - g.doShutdown() - } - }() ctx := g.ShutdownContext() pprof.SetGoroutineLabels(ctx) // We don't have a label to restore back to but I think this is fine run(ctx) diff --git a/modules/graceful/net_unix.go b/modules/graceful/net_unix.go index 796e00507cc..a06f78dafe8 100644 --- a/modules/graceful/net_unix.go +++ b/modules/graceful/net_unix.go @@ -290,11 +290,11 @@ func getActiveListenersToUnlink() []bool { func getNotifySocket() (*net.UnixConn, error) { if err := getProvidedFDs(); err != nil { // This error will be logged elsewhere - return nil, nil + return nil, nil //nolint:nilnil // return nil when no provided FDs are available } if notifySocketAddr == "" { - return nil, nil + return nil, nil //nolint:nilnil // return nil when notify socket is not configured } socketAddr := &net.UnixAddr{ diff --git a/modules/graceful/server.go b/modules/graceful/server.go index 2525a83e775..b440f68ab55 100644 --- a/modules/graceful/server.go +++ b/modules/graceful/server.go @@ -11,7 +11,6 @@ import ( "os" "strings" "sync" - "sync/atomic" "syscall" "time" @@ -30,12 +29,15 @@ type ServeFunction = func(net.Listener) error // Server represents our graceful server type Server struct { - network string - address string - listener net.Listener - wg sync.WaitGroup - state state - lock *sync.RWMutex + network string + address string + listener net.Listener + + lock sync.RWMutex + state state + connCounter int64 + connEmptyCond *sync.Cond + BeforeBegin func(network, address string) OnShutdown func() PerWriteTimeout time.Duration @@ -50,14 +52,13 @@ func NewServer(network, address, name string) *Server { log.Info("Starting new %s server: %s:%s on PID: %d", name, network, address, os.Getpid()) } srv := &Server{ - wg: sync.WaitGroup{}, state: stateInit, - lock: &sync.RWMutex{}, network: network, address: address, PerWriteTimeout: setting.PerWriteTimeout, PerWritePerKbTimeout: setting.PerWritePerKbTimeout, } + srv.connEmptyCond = sync.NewCond(&srv.lock) srv.BeforeBegin = func(network, addr string) { log.Debug("Starting server on %s:%s (PID: %d)", network, addr, syscall.Getpid()) @@ -154,7 +155,7 @@ func (srv *Server) Serve(serve ServeFunction) error { GetManager().RegisterServer() err := serve(srv.listener) log.Debug("Waiting for connections to finish... (PID: %d)", syscall.Getpid()) - srv.wg.Wait() + srv.waitForActiveConnections() srv.setState(stateTerminate) GetManager().ServerDone() // use of closed means that the listeners are closed - i.e. we should be shutting down - return nil @@ -178,16 +179,62 @@ func (srv *Server) setState(st state) { srv.state = st } +func (srv *Server) waitForActiveConnections() { + srv.lock.Lock() + for srv.connCounter > 0 { + srv.connEmptyCond.Wait() + } + srv.lock.Unlock() +} + +func (srv *Server) wrapConnection(c net.Conn) (net.Conn, error) { + srv.lock.Lock() + defer srv.lock.Unlock() + + if srv.state != stateRunning { + _ = c.Close() + return nil, syscall.EINVAL // same as AcceptTCP + } + + srv.connCounter++ + return &wrappedConn{Conn: c, server: srv}, nil +} + +func (srv *Server) removeConnection(_ *wrappedConn) { + srv.lock.Lock() + defer srv.lock.Unlock() + + srv.connCounter-- + if srv.connCounter <= 0 { + srv.connEmptyCond.Broadcast() + } +} + +// closeAllConnections forcefully closes all active connections +func (srv *Server) closeAllConnections() { + srv.lock.Lock() + if srv.connCounter > 0 { + log.Warn("After graceful shutdown period, %d connections are still active. Forcefully close.", srv.connCounter) + srv.connCounter = 0 // OS will close all the connections after the process exits, so we just assume there is no active connection now + } + srv.lock.Unlock() + srv.connEmptyCond.Broadcast() +} + type filer interface { File() (*os.File, error) } type wrappedListener struct { net.Listener - stopped bool - server *Server + server *Server } +var ( + _ net.Listener = (*wrappedListener)(nil) + _ filer = (*wrappedListener)(nil) +) + func newWrappedListener(l net.Listener, srv *Server) *wrappedListener { return &wrappedListener{ Listener: l, @@ -195,46 +242,24 @@ func newWrappedListener(l net.Listener, srv *Server) *wrappedListener { } } -func (wl *wrappedListener) Accept() (net.Conn, error) { - var c net.Conn - // Set keepalive on TCPListeners connections. +func (wl *wrappedListener) Accept() (c net.Conn, err error) { if tcl, ok := wl.Listener.(*net.TCPListener); ok { + // Set keepalive on TCPListeners connections if possible, see http.tcpKeepAliveListener tc, err := tcl.AcceptTCP() if err != nil { return nil, err } - _ = tc.SetKeepAlive(true) // see http.tcpKeepAliveListener - _ = tc.SetKeepAlivePeriod(3 * time.Minute) // see http.tcpKeepAliveListener + _ = tc.SetKeepAlive(true) + _ = tc.SetKeepAlivePeriod(3 * time.Minute) c = tc } else { - var err error c, err = wl.Listener.Accept() if err != nil { return nil, err } } - closed := int32(0) - - c = &wrappedConn{ - Conn: c, - server: wl.server, - closed: &closed, - perWriteTimeout: wl.server.PerWriteTimeout, - perWritePerKbTimeout: wl.server.PerWritePerKbTimeout, - } - - wl.server.wg.Add(1) - return c, nil -} - -func (wl *wrappedListener) Close() error { - if wl.stopped { - return syscall.EINVAL - } - - wl.stopped = true - return wl.Listener.Close() + return wl.server.wrapConnection(c) } func (wl *wrappedListener) File() (*os.File, error) { @@ -244,17 +269,14 @@ func (wl *wrappedListener) File() (*os.File, error) { type wrappedConn struct { net.Conn - server *Server - closed *int32 - deadline time.Time - perWriteTimeout time.Duration - perWritePerKbTimeout time.Duration + server *Server + deadline time.Time } func (w *wrappedConn) Write(p []byte) (n int, err error) { - if w.perWriteTimeout > 0 { - minTimeout := time.Duration(len(p)/1024) * w.perWritePerKbTimeout - minDeadline := time.Now().Add(minTimeout).Add(w.perWriteTimeout) + if w.server.PerWriteTimeout > 0 { + minTimeout := time.Duration(len(p)/1024) * w.server.PerWritePerKbTimeout + minDeadline := time.Now().Add(minTimeout).Add(w.server.PerWriteTimeout) w.deadline = w.deadline.Add(minTimeout) if minDeadline.After(w.deadline) { @@ -266,19 +288,6 @@ func (w *wrappedConn) Write(p []byte) (n int, err error) { } func (w *wrappedConn) Close() error { - if atomic.CompareAndSwapInt32(w.closed, 0, 1) { - defer func() { - if err := recover(); err != nil { - select { - case <-GetManager().IsHammer(): - // Likely deadlocked request released at hammertime - log.Warn("Panic during connection close! %v. Likely there has been a deadlocked request which has been released by forced shutdown.", err) - default: - log.Error("Panic during connection close! %v", err) - } - } - }() - w.server.wg.Done() - } + w.server.removeConnection(w) return w.Conn.Close() } diff --git a/modules/graceful/server_hooks.go b/modules/graceful/server_hooks.go index 9b67589571c..b800c32503e 100644 --- a/modules/graceful/server_hooks.go +++ b/modules/graceful/server_hooks.go @@ -5,7 +5,6 @@ package graceful import ( "os" - "runtime" "code.gitea.io/gitea/modules/log" ) @@ -48,26 +47,8 @@ func (srv *Server) doShutdown() { } func (srv *Server) doHammer() { - defer func() { - // We call srv.wg.Done() until it panics. - // This happens if we call Done() when the WaitGroup counter is already at 0 - // So if it panics -> we're done, Serve() will return and the - // parent will goroutine will exit. - if r := recover(); r != nil { - log.Error("WaitGroup at 0: Error: %v", r) - } - }() if srv.getState() != stateShuttingDown { return } - log.Warn("Forcefully shutting down parent") - for { - if srv.getState() == stateTerminate { - break - } - srv.wg.Done() - - // Give other goroutines a chance to finish before we forcibly stop them. - runtime.Gosched() - } + srv.closeAllConnections() } diff --git a/modules/hcaptcha/hcaptcha_test.go b/modules/hcaptcha/hcaptcha_test.go index 55e01ec5355..6b207bfb778 100644 --- a/modules/hcaptcha/hcaptcha_test.go +++ b/modules/hcaptcha/hcaptcha_test.go @@ -4,8 +4,10 @@ package hcaptcha import ( + "errors" + "io" "net/http" - "os" + "net/url" "strings" "testing" "time" @@ -17,8 +19,31 @@ const ( dummyToken = "10000000-aaaa-bbbb-cccc-000000000001" ) -func TestMain(m *testing.M) { - os.Exit(m.Run()) +type mockTransport struct{} + +func (mockTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if req.URL.String() != verifyURL { + return nil, errors.New("unsupported url") + } + + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + + bodyValues, err := url.ParseQuery(string(body)) + if err != nil { + return nil, err + } + + var responseText string + if bodyValues.Get("response") == dummyToken { + responseText = `{"success":true,"credit":false,"hostname":"dummy-key-pass","challenge_ts":"2025-10-08T16:02:56.136Z"}` + } else { + responseText = `{"success":false,"error-codes":["invalid-input-response"]}` + } + + return &http.Response{Request: req, Body: io.NopCloser(strings.NewReader(responseText))}, nil } func TestCaptcha(t *testing.T) { @@ -54,7 +79,8 @@ func TestCaptcha(t *testing.T) { for _, tc := range tt { t.Run(tc.Name, func(t *testing.T) { client, err := New(tc.Secret, WithHTTP(&http.Client{ - Timeout: time.Second * 5, + Timeout: time.Second * 5, + Transport: mockTransport{}, })) if err != nil { // The only error that can be returned from creating a client diff --git a/modules/highlight/highlight.go b/modules/highlight/highlight.go index 77f24fa3f38..c7416c7a10c 100644 --- a/modules/highlight/highlight.go +++ b/modules/highlight/highlight.go @@ -11,104 +11,94 @@ import ( gohtml "html" "html/template" "io" - "path" - "path/filepath" "strings" "sync" - "code.gitea.io/gitea/modules/analyze" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "github.com/alecthomas/chroma/v2" "github.com/alecthomas/chroma/v2/formatters/html" - "github.com/alecthomas/chroma/v2/lexers" "github.com/alecthomas/chroma/v2/styles" - lru "github.com/hashicorp/golang-lru/v2" ) // don't index files larger than this many bytes for performance purposes const sizeLimit = 1024 * 1024 -var ( - // For custom user mapping - highlightMapping = map[string]string{} - - once sync.Once - - cache *lru.TwoQueueCache[string, any] - - githubStyles = styles.Get("github") -) - -// NewContext loads custom highlight map from local config -func NewContext() { - once.Do(func() { - highlightMapping = setting.GetHighlightMapping() - - // The size 512 is simply a conservative rule of thumb - c, err := lru.New2Q[string, any](512) - if err != nil { - panic(fmt.Sprintf("failed to initialize LRU cache for highlighter: %s", err)) - } - cache = c - }) +type globalVarsType struct { + highlightMapping map[string]string + githubStyles *chroma.Style } -// Code returns a HTML version of code string with chroma syntax highlighting classes and the matched lexer name -func Code(fileName, language, code string) (output template.HTML, lexerName string) { - NewContext() +var ( + globalVarsMu sync.Mutex + globalVarsPtr *globalVarsType +) +func globalVars() *globalVarsType { + // in the future, the globalVars might need to be re-initialized when settings change, so don't use sync.Once here + globalVarsMu.Lock() + defer globalVarsMu.Unlock() + if globalVarsPtr == nil { + globalVarsPtr = &globalVarsType{} + globalVarsPtr.githubStyles = styles.Get("github") + globalVarsPtr.highlightMapping = setting.GetHighlightMapping() + } + return globalVarsPtr +} + +// UnsafeSplitHighlightedLines splits highlighted code into lines preserving HTML tags +// It always includes '\n', '\n' can appear at the end of each line or in the middle of HTML tags +// The '\n' is necessary for copying code from web UI to preserve original code lines +// ATTENTION: It uses the unsafe conversion between string and []byte for performance reason +// DO NOT make any modification to the returned [][]byte slice items +func UnsafeSplitHighlightedLines(code template.HTML) (ret [][]byte) { + buf := util.UnsafeStringToBytes(string(code)) + lineCount := bytes.Count(buf, []byte("\n")) + 1 + ret = make([][]byte, 0, lineCount) + nlTagClose := []byte("\n 0 { + ret = append(ret, buf) + } + return ret + } + // Chroma highlighting output sometimes have "" right after \n, sometimes before. + // * "text\n" + // * "text\n" + if bytes.HasPrefix(buf[pos:], nlTagClose) { + pos1 := bytes.IndexByte(buf[pos:], '>') + if pos1 != -1 { + pos += pos1 + } + } + ret = append(ret, buf[:pos+1]) + buf = buf[pos+1:] + } +} + +// RenderCodeSlowGuess tries to get a lexer by file name and language first, +// if not found, it will try to guess the lexer by code content, which is slow (more than several hundreds of milliseconds). +func RenderCodeSlowGuess(fileName, language, code string) (output template.HTML, lexer chroma.Lexer, lexerDisplayName string) { // diff view newline will be passed as empty, change to literal '\n' so it can be copied // preserve literal newline in blame view if code == "" || code == "\n" { - return "\n", "" + return "\n", nil, "" } if len(code) > sizeLimit { - return template.HTML(template.HTMLEscapeString(code)), "" + return template.HTML(template.HTMLEscapeString(code)), nil, "" } - var lexer chroma.Lexer - - if len(language) > 0 { - lexer = lexers.Get(language) - - if lexer == nil { - // Attempt stripping off the '?' - if idx := strings.IndexByte(language, '?'); idx > 0 { - lexer = lexers.Get(language[:idx]) - } - } - } - - if lexer == nil { - if val, ok := highlightMapping[path.Ext(fileName)]; ok { - // use mapped value to find lexer - lexer = lexers.Get(val) - } - } - - if lexer == nil { - if l, ok := cache.Get(fileName); ok { - lexer = l.(chroma.Lexer) - } - } - - if lexer == nil { - lexer = lexers.Match(fileName) - if lexer == nil { - lexer = lexers.Fallback - } - cache.Add(fileName, lexer) - } - - return CodeFromLexer(lexer, code), formatLexerName(lexer.Config().Name) + lexer = detectChromaLexerWithAnalyze(fileName, language, util.UnsafeStringToBytes(code)) // it is also slow + return RenderCodeByLexer(lexer, code), lexer, formatLexerName(lexer.Config().Name) } -// CodeFromLexer returns a HTML version of code string with chroma syntax highlighting classes -func CodeFromLexer(lexer chroma.Lexer, code string) template.HTML { +// RenderCodeByLexer returns a HTML version of code string with chroma syntax highlighting classes +func RenderCodeByLexer(lexer chroma.Lexer, code string) template.HTML { formatter := html.New(html.WithClasses(true), html.WithLineNumbers(false), html.PreventSurroundingPre(true), @@ -123,7 +113,7 @@ func CodeFromLexer(lexer chroma.Lexer, code string) template.HTML { return template.HTML(template.HTMLEscapeString(code)) } // style not used for live site but need to pass something - err = formatter.Format(htmlw, githubStyles, iterator) + err = formatter.Format(htmlw, globalVars().githubStyles, iterator) if err != nil { log.Error("Can't format code: %v", err) return template.HTML(template.HTMLEscapeString(code)) @@ -135,12 +125,10 @@ func CodeFromLexer(lexer chroma.Lexer, code string) template.HTML { return template.HTML(strings.TrimSuffix(htmlbuf.String(), "\n")) } -// File returns a slice of chroma syntax highlighted HTML lines of code and the matched lexer name -func File(fileName, language string, code []byte) ([]template.HTML, string, error) { - NewContext() - +// RenderFullFile returns a slice of chroma syntax highlighted HTML lines of code and the matched lexer name +func RenderFullFile(fileName, language string, code []byte) ([]template.HTML, string, error) { if len(code) > sizeLimit { - return PlainText(code), "", nil + return RenderPlainText(code), "", nil } formatter := html.New(html.WithClasses(true), @@ -148,31 +136,7 @@ func File(fileName, language string, code []byte) ([]template.HTML, string, erro html.PreventSurroundingPre(true), ) - var lexer chroma.Lexer - - // provided language overrides everything - if language != "" { - lexer = lexers.Get(language) - } - - if lexer == nil { - if val, ok := highlightMapping[filepath.Ext(fileName)]; ok { - lexer = lexers.Get(val) - } - } - - if lexer == nil { - guessLanguage := analyze.GetCodeLanguage(fileName, code) - - lexer = lexers.Get(guessLanguage) - if lexer == nil { - lexer = lexers.Match(fileName) - if lexer == nil { - lexer = lexers.Fallback - } - } - } - + lexer := detectChromaLexerWithAnalyze(fileName, language, code) lexerName := formatLexerName(lexer.Config().Name) iterator, err := lexer.Tokenise(nil, string(code)) @@ -186,7 +150,7 @@ func File(fileName, language string, code []byte) ([]template.HTML, string, erro lines := make([]template.HTML, 0, len(tokensLines)) for _, tokens := range tokensLines { iterator = chroma.Literator(tokens...) - err = formatter.Format(htmlBuf, githubStyles, iterator) + err = formatter.Format(htmlBuf, globalVars().githubStyles, iterator) if err != nil { return nil, "", fmt.Errorf("can't format code: %w", err) } @@ -197,8 +161,8 @@ func File(fileName, language string, code []byte) ([]template.HTML, string, erro return lines, lexerName, nil } -// PlainText returns non-highlighted HTML for code -func PlainText(code []byte) []template.HTML { +// RenderPlainText returns non-highlighted HTML for code +func RenderPlainText(code []byte) []template.HTML { r := bufio.NewReader(bytes.NewReader(code)) m := make([]template.HTML, 0, bytes.Count(code, []byte{'\n'})+1) for { diff --git a/modules/highlight/highlight_test.go b/modules/highlight/highlight_test.go index b36de98c5c2..d026210475c 100644 --- a/modules/highlight/highlight_test.go +++ b/modules/highlight/highlight_test.go @@ -108,11 +108,17 @@ c=2 ), lexerName: "Python", }, + { + name: "test.sql", + code: "--\nSELECT", + want: []template.HTML{"--\n", `SELECT`}, + lexerName: "SQL", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - out, lexerName, err := File(tt.name, "", []byte(tt.code)) + out, lexerName, err := RenderFullFile(tt.name, "", []byte(tt.code)) assert.NoError(t, err) assert.Equal(t, tt.want, out) assert.Equal(t, tt.lexerName, lexerName) @@ -176,8 +182,26 @@ c=2`), for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - out := PlainText([]byte(tt.code)) + out := RenderPlainText([]byte(tt.code)) assert.Equal(t, tt.want, out) }) } } + +func TestUnsafeSplitHighlightedLines(t *testing.T) { + ret := UnsafeSplitHighlightedLines("") + assert.Empty(t, ret) + + ret = UnsafeSplitHighlightedLines("a") + assert.Len(t, ret, 1) + assert.Equal(t, "a", string(ret[0])) + + ret = UnsafeSplitHighlightedLines("\n") + assert.Len(t, ret, 1) + assert.Equal(t, "\n", string(ret[0])) + + ret = UnsafeSplitHighlightedLines("a\nb\n") + assert.Len(t, ret, 2) + assert.Equal(t, "a\n", string(ret[0])) + assert.Equal(t, "b\n", string(ret[1])) +} diff --git a/modules/highlight/lexerdetect.go b/modules/highlight/lexerdetect.go new file mode 100644 index 00000000000..5d98578f353 --- /dev/null +++ b/modules/highlight/lexerdetect.go @@ -0,0 +1,307 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package highlight + +import ( + "path" + "strings" + "sync" + + "code.gitea.io/gitea/modules/analyze" + "code.gitea.io/gitea/modules/log" + + "github.com/alecthomas/chroma/v2" + "github.com/alecthomas/chroma/v2/lexers" + "github.com/go-enry/go-enry/v2" +) + +const mapKeyLowerPrefix = "lower/" + +// chromaLexers is fully managed by us to do fast lookup for chroma lexers by file name or language name +// Don't use lexers.Get because it is very slow in many cases (iterate all rules, filepath glob match, etc.) +var chromaLexers = sync.OnceValue(func() (ret struct { + conflictingExtLangMap map[string]string + conflictingAliasLangMap map[string]string + + lowerNameMap map[string]chroma.Lexer // lexer name (lang name) in lower-case + fileBaseMap map[string]chroma.Lexer + fileExtMap map[string]chroma.Lexer + fileParts []struct { + part string + lexer chroma.Lexer + } +}, +) { + ret.lowerNameMap = make(map[string]chroma.Lexer) + ret.fileBaseMap = make(map[string]chroma.Lexer) + ret.fileExtMap = make(map[string]chroma.Lexer) + + // Chroma has conflicts in file extension for different languages, + // When we need to do fast render, there is no way to detect the language by content, + // So we can only choose some default languages for the conflicted file extensions. + ret.conflictingExtLangMap = map[string]string{ + ".as": "ActionScript 3", // ActionScript + ".asm": "NASM", // TASM, NASM, RGBDS Assembly, Z80 Assembly + ".ASM": "NASM", + ".bas": "VB.net", // QBasic + ".bf": "Beef", // Brainfuck + ".fs": "FSharp", // Forth + ".gd": "GDScript", // GDScript3 + ".h": "C", // Objective-C + ".hcl": "Terraform", // HCL + ".hh": "C++", // HolyC + ".inc": "PHP", // ObjectPascal, POVRay, SourcePawn, PHTML + ".m": "Objective-C", // Matlab, Mathematica, Mason + ".mc": "Mason", // MonkeyC + ".network": "SYSTEMD", // INI + ".php": "PHP", // PHTML + ".php3": "PHP", // PHTML + ".php4": "PHP", // PHTML + ".php5": "PHP", // PHTML + ".pl": "Perl", // Prolog, Raku + ".pm": "Perl", // Promela, Raku + ".pp": "ObjectPascal", // Puppet + ".s": "ArmAsm", // GAS + ".S": "ArmAsm", // R, GAS + ".service": "SYSTEMD", // INI + ".socket": "SYSTEMD", // INI + ".sql": "SQL", // MySQL + ".t": "Perl", // Raku + ".ts": "TypeScript", // TypoScript + ".v": "V", // verilog + ".xslt": "HTML", // XML + } + // use widely used language names as the default mapping to resolve name alias conflict + ret.conflictingAliasLangMap = map[string]string{ + "hcl": "HCL", // Terraform + "v": "V", // verilog + } + + isPlainPattern := func(key string) bool { + return !strings.ContainsAny(key, "*?[]") // only support simple patterns + } + + setFileNameMapWithLowerKey := func(m map[string]chroma.Lexer, key string, lexer chroma.Lexer) { + if _, conflict := m[key]; conflict { + panic("duplicate key in lexer map: " + key + ", need to add it to conflictingExtLangMap") + } + m[key] = lexer + m[mapKeyLowerPrefix+strings.ToLower(key)] = lexer + } + + processFileName := func(fileName string, lexer chroma.Lexer) bool { + if isPlainPattern(fileName) { + // full base name match + setFileNameMapWithLowerKey(ret.fileBaseMap, fileName, lexer) + return true + } + if strings.HasPrefix(fileName, "*") { + // ext name match: "*.js" + fileExt := strings.Trim(fileName, "*") + if isPlainPattern(fileExt) { + presetName := ret.conflictingExtLangMap[fileExt] + if presetName == "" || lexer.Config().Name == presetName { + setFileNameMapWithLowerKey(ret.fileExtMap, fileExt, lexer) + } + return true + } + } + if strings.HasSuffix(fileName, "*") { + // part match: "*.env.*" + filePart := strings.Trim(fileName, "*") + if isPlainPattern(filePart) { + ret.fileParts = append(ret.fileParts, struct { + part string + lexer chroma.Lexer + }{ + part: filePart, + lexer: lexer, + }) + return true + } + } + return false + } + + expandGlobPatterns := func(patterns []string) []string { + // expand patterns like "file.[ch]" to "file.c" and "file.h", only one pair of "[]" is supported, enough for current Chroma lexers + for idx, s := range patterns { + idx1 := strings.IndexByte(s, '[') + idx2 := strings.IndexByte(s, ']') + if idx1 != -1 && idx2 != -1 && idx2 > idx1+1 { + left, mid, right := s[:idx1], s[idx1+1:idx2], s[idx2+1:] + patterns[idx] = left + mid[0:1] + right + for i := 1; i < len(mid); i++ { + patterns = append(patterns, left+mid[i:i+1]+right) + } + } + } + return patterns + } + + processLexerNameAliases := func(lexer chroma.Lexer) { + cfg := lexer.Config() + lowerName := strings.ToLower(cfg.Name) + if _, conflicted := ret.lowerNameMap[lowerName]; conflicted { + panic("duplicate language name in lexer map: " + lowerName) + } + ret.lowerNameMap[lowerName] = lexer + + for _, name := range cfg.Aliases { + lowerName := strings.ToLower(name) + if overriddenName, overridden := ret.conflictingAliasLangMap[lowerName]; overridden && overriddenName != cfg.Name { + continue + } + if existingLexer, conflict := ret.lowerNameMap[lowerName]; conflict && existingLexer.Config().Name != cfg.Name { + panic("duplicate alias in lexer map: " + name + ", conflict between " + existingLexer.Config().Name + " and " + cfg.Name) + } + ret.lowerNameMap[lowerName] = lexer + } + } + + // the main loop: build our lookup maps for lexers + for _, lexer := range lexers.GlobalLexerRegistry.Lexers { + cfg := lexer.Config() + processLexerNameAliases(lexer) + for _, s := range expandGlobPatterns(cfg.Filenames) { + if !processFileName(s, lexer) { + panic("unsupported file name pattern in lexer: " + s) + } + } + for _, s := range expandGlobPatterns(cfg.AliasFilenames) { + if !processFileName(s, lexer) { + panic("unsupported alias file name pattern in lexer: " + s) + } + } + } + + // final check: make sure the default overriding mapping is correct, nothing is missing + for lowerName, lexerName := range ret.conflictingAliasLangMap { + if lexer, ok := ret.lowerNameMap[lowerName]; !ok || lexer.Config().Name != lexerName { + panic("missing default name-lang mapping for: " + lowerName) + } + } + for ext, lexerName := range ret.conflictingExtLangMap { + if lexer, ok := ret.fileExtMap[ext]; !ok || lexer.Config().Name != lexerName { + panic("missing default ext-lang mapping for: " + ext) + } + } + return ret +}) + +func normalizeFileNameLang(fileName, fileLang string) (string, string) { + fileName = path.Base(fileName) + fileLang, _, _ = strings.Cut(fileLang, "?") // maybe, the value from gitattributes might contain `?` parameters? + ext := path.Ext(fileName) + // the "lang" might come from enry or gitattributes, it has different naming for some languages + switch fileLang { + case "F#": + fileLang = "FSharp" + case "Pascal": + fileLang = "ObjectPascal" + case "C": + if ext == ".C" || ext == ".H" { + fileLang = "C++" + } + } + return fileName, fileLang +} + +func DetectChromaLexerByFileName(fileName, fileLang string) chroma.Lexer { + lexer, _ := detectChromaLexerByFileName(fileName, fileLang) + return lexer +} + +func detectChromaLexerByFileName(fileName, fileLang string) (_ chroma.Lexer, byLang bool) { + fileName, fileLang = normalizeFileNameLang(fileName, fileLang) + fileExt := path.Ext(fileName) + + // apply custom mapping for file extension, highest priority, for example: + // * ".my-js" -> ".js" + // * ".my-html" -> "HTML" + if fileExt != "" { + if val, ok := globalVars().highlightMapping[fileExt]; ok { + if strings.HasPrefix(val, ".") { + fileName = "dummy" + val + fileLang = "" + } else { + fileLang = val + } + } + } + + // try to use language for lexer name + if fileLang != "" { + lexer := chromaLexers().lowerNameMap[strings.ToLower(fileLang)] + if lexer != nil { + return lexer, true + } + } + + if fileName == "" { + return lexers.Fallback, false + } + + // try base name + { + baseName := path.Base(fileName) + if lexer, ok := chromaLexers().fileBaseMap[baseName]; ok { + return lexer, false + } else if lexer, ok = chromaLexers().fileBaseMap[mapKeyLowerPrefix+strings.ToLower(baseName)]; ok { + return lexer, false + } + } + + if fileExt == "" { + return lexers.Fallback, false + } + + // try ext name + { + if lexer, ok := chromaLexers().fileExtMap[fileExt]; ok { + return lexer, false + } else if lexer, ok = chromaLexers().fileExtMap[mapKeyLowerPrefix+strings.ToLower(fileExt)]; ok { + return lexer, false + } + } + + // try file part match, for example: ".env.local" for "*.env.*" + // it assumes that there must be a dot in filename (fileExt isn't empty) + for _, item := range chromaLexers().fileParts { + if strings.Contains(fileName, item.part) { + return item.lexer, false + } + } + return lexers.Fallback, false +} + +// detectChromaLexerWithAnalyze returns a chroma lexer by given file name, language and code content. All parameters can be optional. +// When code content is provided, it will be slow if no lexer is found by file name or language. +// If no lexer is found, it will return the fallback lexer. +func detectChromaLexerWithAnalyze(fileName, lang string, code []byte) chroma.Lexer { + lexer, byLang := detectChromaLexerByFileName(fileName, lang) + + // if lang is provided, and it matches a lexer, use it directly + if byLang { + return lexer + } + + // if a lexer is detected and there is no conflict for the file extension, use it directly + fileExt := path.Ext(fileName) + _, hasConflicts := chromaLexers().conflictingExtLangMap[fileExt] + if !hasConflicts && lexer != lexers.Fallback { + return lexer + } + + // try to detect language by content, for best guessing for the language + // when using "code" to detect, analyze.GetCodeLanguage is slow, it iterates many rules to detect language from content + analyzedLanguage := analyze.GetCodeLanguage(fileName, code) + lexer = DetectChromaLexerByFileName(fileName, analyzedLanguage) + if lexer == lexers.Fallback { + if analyzedLanguage != enry.OtherLanguage { + log.Warn("No chroma lexer found for enry detected language: %s (file: %s), need to fix the language mapping between enry and chroma.", analyzedLanguage, fileName) + } + } + return lexer +} diff --git a/modules/highlight/lexerdetect_test.go b/modules/highlight/lexerdetect_test.go new file mode 100644 index 00000000000..a06053be0c9 --- /dev/null +++ b/modules/highlight/lexerdetect_test.go @@ -0,0 +1,116 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package highlight + +import ( + "strings" + "testing" + + "github.com/alecthomas/chroma/v2/lexers" + "github.com/stretchr/testify/assert" +) + +func BenchmarkDetectChromaLexerByFileName(b *testing.B) { + for b.Loop() { + // BenchmarkDetectChromaLexerByFileName-12 18214717 61.35 ns/op + DetectChromaLexerByFileName("a.sql", "") + } +} + +func BenchmarkDetectChromaLexerWithAnalyze(b *testing.B) { + b.StopTimer() + code := []byte(strings.Repeat("SELECT * FROM table;\n", 1000)) + b.StartTimer() + for b.Loop() { + // BenchmarkRenderCodeSlowGuess-12 87946 13310 ns/op + detectChromaLexerWithAnalyze("a", "", code) + } +} + +func BenchmarkChromaAnalyze(b *testing.B) { + b.StopTimer() + code := strings.Repeat("SELECT * FROM table;\n", 1000) + b.StartTimer() + for b.Loop() { + // comparing to detectChromaLexerWithAnalyze (go-enry), "chroma/lexers.Analyse" is very slow + // BenchmarkChromaAnalyze-12 519 2247104 ns/op + lexers.Analyse(code) + } +} + +func BenchmarkRenderCodeByLexer(b *testing.B) { + b.StopTimer() + code := strings.Repeat("SELECT * FROM table;\n", 1000) + lexer := DetectChromaLexerByFileName("a.sql", "") + b.StartTimer() + for b.Loop() { + // Really slow ....... the regexp2 used by Chroma takes most of the time + // BenchmarkRenderCodeByLexer-12 22 47159038 ns/op + RenderCodeByLexer(lexer, code) + } +} + +func TestDetectChromaLexer(t *testing.T) { + globalVars().highlightMapping[".my-html"] = "HTML" + t.Cleanup(func() { delete(globalVars().highlightMapping, ".my-html") }) + + casesWithContent := []struct { + fileName string + language string + content string + expected string + }{ + {"test.v", "", "", "V"}, + {"test.v", "any-lang-name", "", "V"}, + + {"any-file", "javascript", "", "JavaScript"}, + {"any-file", "", "/* vim: set filetype=python */", "Python"}, + {"any-file", "", "", "fallback"}, + + {"test.fs", "", "", "FSharp"}, + {"test.fs", "F#", "", "FSharp"}, + {"test.fs", "", "let x = 1", "FSharp"}, + + {"test.c", "", "", "C"}, + {"test.C", "", "", "C++"}, + {"OLD-CODE.PAS", "", "", "ObjectPascal"}, + {"test.my-html", "", "", "HTML"}, + + {"a.php", "", "", "PHP"}, + {"a.sql", "", "", "SQL"}, + {"dhcpd.conf", "", "", "ISCdhcpd"}, + {".env.my-production", "", "", "Bash"}, + + {"a.hcl", "", "", "HCL"}, // not the same as Chroma, enry detects "*.hcl" as "HCL" + {"a.hcl", "HCL", "", "HCL"}, + {"a.hcl", "Terraform", "", "Terraform"}, + } + for _, c := range casesWithContent { + lexer := detectChromaLexerWithAnalyze(c.fileName, c.language, []byte(c.content)) + if assert.NotNil(t, lexer, "case: %+v", c) { + assert.Equal(t, c.expected, lexer.Config().Name, "case: %+v", c) + } + } + + casesNameLang := []struct { + fileName string + language string + expected string + byLang bool + }{ + {"a.v", "", "V", false}, + {"a.v", "V", "V", true}, + {"a.v", "verilog", "verilog", true}, + {"a.v", "any-lang-name", "V", false}, + + {"a.hcl", "", "Terraform", false}, // not the same as enry + {"a.hcl", "HCL", "HCL", true}, + {"a.hcl", "Terraform", "Terraform", true}, + } + for _, c := range casesNameLang { + lexer, byLang := detectChromaLexerByFileName(c.fileName, c.language) + assert.Equal(t, c.expected, lexer.Config().Name, "case: %+v", c) + assert.Equal(t, c.byLang, byLang, "case: %+v", c) + } +} diff --git a/modules/htmlutil/html.go b/modules/htmlutil/html.go index efbc174b2ea..8dbfe0c22e2 100644 --- a/modules/htmlutil/html.go +++ b/modules/htmlutil/html.go @@ -6,6 +6,7 @@ package htmlutil import ( "fmt" "html/template" + "io" "slices" "strings" ) @@ -31,7 +32,7 @@ func ParseSizeAndClass(defaultSize int, defaultClass string, others ...any) (int return size, class } -func HTMLFormat(s template.HTML, rawArgs ...any) template.HTML { +func htmlFormatArgs(s template.HTML, rawArgs []any) []any { if !strings.Contains(string(s), "%") || len(rawArgs) == 0 { panic("HTMLFormat requires one or more arguments") } @@ -50,5 +51,35 @@ func HTMLFormat(s template.HTML, rawArgs ...any) template.HTML { args[i] = template.HTMLEscapeString(fmt.Sprint(v)) } } - return template.HTML(fmt.Sprintf(string(s), args...)) + return args +} + +func HTMLFormat(s template.HTML, rawArgs ...any) template.HTML { + return template.HTML(fmt.Sprintf(string(s), htmlFormatArgs(s, rawArgs)...)) +} + +func HTMLPrintf(w io.Writer, s template.HTML, rawArgs ...any) (int, error) { + return fmt.Fprintf(w, string(s), htmlFormatArgs(s, rawArgs)...) +} + +func HTMLPrint(w io.Writer, s template.HTML) (int, error) { + return io.WriteString(w, string(s)) +} + +func HTMLPrintTag(w io.Writer, tag template.HTML, attrs map[string]string) (written int, err error) { + n, err := io.WriteString(w, "<"+string(tag)) + written += n + if err != nil { + return written, err + } + for k, v := range attrs { + n, err = fmt.Fprintf(w, ` %s="%s"`, template.HTMLEscapeString(k), template.HTMLEscapeString(v)) + written += n + if err != nil { + return written, err + } + } + n, err = io.WriteString(w, ">") + written += n + return written, err } diff --git a/modules/httpcache/httpcache.go b/modules/httpcache/httpcache.go index dd3efab7a57..e23cf0a92a2 100644 --- a/modules/httpcache/httpcache.go +++ b/modules/httpcache/httpcache.go @@ -60,21 +60,6 @@ func CacheControlForPrivateStatic() *CacheControlOptions { } } -// HandleGenericETagCache handles ETag-based caching for a HTTP request. -// It returns true if the request was handled. -func HandleGenericETagCache(req *http.Request, w http.ResponseWriter, etag string) (handled bool) { - if len(etag) > 0 { - w.Header().Set("Etag", etag) - if checkIfNoneMatchIsValid(req, etag) { - w.WriteHeader(http.StatusNotModified) - return true - } - } - // not sure whether it is a public content, so just use "private" (old behavior) - SetCacheControlInHeader(w.Header(), CacheControlForPrivateStatic()) - return false -} - // checkIfNoneMatchIsValid tests if the header If-None-Match matches the ETag func checkIfNoneMatchIsValid(req *http.Request, etag string) bool { ifNoneMatch := req.Header.Get("If-None-Match") @@ -89,10 +74,18 @@ func checkIfNoneMatchIsValid(req *http.Request, etag string) bool { return false } -// HandleGenericETagTimeCache handles ETag-based caching with Last-Modified caching for a HTTP request. +func HandleGenericETagPublicCache(req *http.Request, w http.ResponseWriter, etag string, lastModified *time.Time) bool { + return handleGenericETagTimeCache(req, w, etag, lastModified, CacheControlForPublicStatic()) +} + +func HandleGenericETagPrivateCache(req *http.Request, w http.ResponseWriter, etag string, lastModified *time.Time) bool { + return handleGenericETagTimeCache(req, w, etag, lastModified, CacheControlForPrivateStatic()) +} + +// handleGenericETagTimeCache handles ETag-based caching with Last-Modified caching for the HTTP request. // It returns true if the request was handled. -func HandleGenericETagTimeCache(req *http.Request, w http.ResponseWriter, etag string, lastModified *time.Time) (handled bool) { - if len(etag) > 0 { +func handleGenericETagTimeCache(req *http.Request, w http.ResponseWriter, etag string, lastModified *time.Time, cacheControlOpts *CacheControlOptions) (handled bool) { + if etag != "" { w.Header().Set("Etag", etag) } if lastModified != nil && !lastModified.IsZero() { @@ -100,7 +93,7 @@ func HandleGenericETagTimeCache(req *http.Request, w http.ResponseWriter, etag s w.Header().Set("Last-Modified", lastModified.UTC().Format(http.TimeFormat)) } - if len(etag) > 0 { + if etag != "" { if checkIfNoneMatchIsValid(req, etag) { w.WriteHeader(http.StatusNotModified) return true @@ -117,7 +110,6 @@ func HandleGenericETagTimeCache(req *http.Request, w http.ResponseWriter, etag s } } - // not sure whether it is a public content, so just use "private" (old behavior) - SetCacheControlInHeader(w.Header(), CacheControlForPrivateStatic()) + SetCacheControlInHeader(w.Header(), cacheControlOpts) return false } diff --git a/modules/httpcache/httpcache_test.go b/modules/httpcache/httpcache_test.go index d81f06097c1..d510cbe4d7f 100644 --- a/modules/httpcache/httpcache_test.go +++ b/modules/httpcache/httpcache_test.go @@ -6,92 +6,73 @@ package httpcache import ( "net/http" "net/http/httptest" - "strings" "testing" + "time" + + "code.gitea.io/gitea/modules/util" "github.com/stretchr/testify/assert" ) -func countFormalHeaders(h http.Header) (c int) { - for k := range h { - // ignore our headers for internal usage - if strings.HasPrefix(k, "X-Gitea-") { - continue - } - c++ - } - return c -} - func TestHandleGenericETagCache(t *testing.T) { - etag := `"test"` + matchedEtag := `"matched-etag"` + lastModifiedTime := new(time.Date(2021, time.January, 2, 15, 4, 5, 0, time.FixedZone("test-zone", 8*3600))) + lastModified := lastModifiedTime.UTC().Format(http.TimeFormat) + cacheControl := "max-age=0, private, must-revalidate, no-transform" + type testCase struct { + name string + reqHeaders map[string]string + wantHandled bool + wantHeaders map[string]string + wantStatus int + } + cases := []testCase{ + { + name: "No If-None-Match", + wantHandled: false, + wantHeaders: map[string]string{"Last-Modified": lastModified, "Cache-Control": cacheControl, "Etag": matchedEtag}, + }, + { + name: "Mismatched If-None-Match", + reqHeaders: map[string]string{"If-None-Match": `"mismatched-etag"`}, + wantHandled: false, + wantHeaders: map[string]string{"Last-Modified": lastModified, "Cache-Control": cacheControl, "Etag": matchedEtag}, + }, + { + name: "Matched If-None-Match", + reqHeaders: map[string]string{"If-None-Match": matchedEtag}, + wantHandled: true, + wantHeaders: map[string]string{"Last-Modified": lastModified, "Cache-Control": "", "Etag": matchedEtag}, + wantStatus: http.StatusNotModified, + }, + { + name: "Multiple Mismatched If-None-Match", + reqHeaders: map[string]string{"If-None-Match": `"mismatched-etag1", "mismatched-etag2"`}, + wantHandled: false, + wantHeaders: map[string]string{"Last-Modified": lastModified, "Cache-Control": cacheControl, "Etag": matchedEtag}, + }, + { + name: "Multiple Matched If-None-Match", + reqHeaders: map[string]string{"If-None-Match": `"mismatched-etag", ` + matchedEtag}, + wantHandled: true, + wantHeaders: map[string]string{"Last-Modified": lastModified, "Cache-Control": "", "Etag": matchedEtag}, + wantStatus: http.StatusNotModified, + }, + } - t.Run("No_If-None-Match", func(t *testing.T) { - req := &http.Request{Header: make(http.Header)} - w := httptest.NewRecorder() - - handled := HandleGenericETagCache(req, w, etag) - - assert.False(t, handled) - assert.Equal(t, 2, countFormalHeaders(w.Header())) - assert.Contains(t, w.Header(), "Cache-Control") - assert.Contains(t, w.Header(), "Etag") - assert.Equal(t, etag, w.Header().Get("Etag")) - }) - t.Run("Wrong_If-None-Match", func(t *testing.T) { - req := &http.Request{Header: make(http.Header)} - w := httptest.NewRecorder() - - req.Header.Set("If-None-Match", `"wrong etag"`) - - handled := HandleGenericETagCache(req, w, etag) - - assert.False(t, handled) - assert.Equal(t, 2, countFormalHeaders(w.Header())) - assert.Contains(t, w.Header(), "Cache-Control") - assert.Contains(t, w.Header(), "Etag") - assert.Equal(t, etag, w.Header().Get("Etag")) - }) - t.Run("Correct_If-None-Match", func(t *testing.T) { - req := &http.Request{Header: make(http.Header)} - w := httptest.NewRecorder() - - req.Header.Set("If-None-Match", etag) - - handled := HandleGenericETagCache(req, w, etag) - - assert.True(t, handled) - assert.Equal(t, 1, countFormalHeaders(w.Header())) - assert.Contains(t, w.Header(), "Etag") - assert.Equal(t, etag, w.Header().Get("Etag")) - assert.Equal(t, http.StatusNotModified, w.Code) - }) - t.Run("Multiple_Wrong_If-None-Match", func(t *testing.T) { - req := &http.Request{Header: make(http.Header)} - w := httptest.NewRecorder() - - req.Header.Set("If-None-Match", `"wrong etag", "wrong etag "`) - - handled := HandleGenericETagCache(req, w, etag) - - assert.False(t, handled) - assert.Equal(t, 2, countFormalHeaders(w.Header())) - assert.Contains(t, w.Header(), "Cache-Control") - assert.Contains(t, w.Header(), "Etag") - assert.Equal(t, etag, w.Header().Get("Etag")) - }) - t.Run("Multiple_Correct_If-None-Match", func(t *testing.T) { - req := &http.Request{Header: make(http.Header)} - w := httptest.NewRecorder() - - req.Header.Set("If-None-Match", `"wrong etag", `+etag) - - handled := HandleGenericETagCache(req, w, etag) - - assert.True(t, handled) - assert.Equal(t, 1, countFormalHeaders(w.Header())) - assert.Contains(t, w.Header(), "Etag") - assert.Equal(t, etag, w.Header().Get("Etag")) - assert.Equal(t, http.StatusNotModified, w.Code) - }) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "http://example.com/test", nil) + for k, v := range tc.reqHeaders { + req.Header.Set(k, v) + } + w := httptest.NewRecorder() + assert.Equal(t, tc.wantHandled, HandleGenericETagPrivateCache(req, w, matchedEtag, lastModifiedTime)) + resp := w.Result() + for k, v := range tc.wantHeaders { + assert.Equal(t, v, resp.Header.Get(k)) + } + assert.Equal(t, tc.wantStatus, util.Iif(resp.StatusCode == http.StatusOK, 0, resp.StatusCode)) + }) + } } diff --git a/modules/httplib/request.go b/modules/httplib/request.go index 49ea6f4b73e..8542a57d367 100644 --- a/modules/httplib/request.go +++ b/modules/httplib/request.go @@ -7,54 +7,53 @@ package httplib import ( "bytes" "context" - "crypto/tls" - "errors" "fmt" "io" "net" "net/http" "net/url" "strings" + "sync" "time" ) -var defaultSetting = Settings{"GiteaServer", 60 * time.Second, 60 * time.Second, nil, nil} - -// newRequest returns *Request with specific method -func newRequest(url, method string) *Request { - var resp http.Response - req := http.Request{ - Method: method, - Header: make(http.Header), - Proto: "HTTP/1.1", - ProtoMajor: 1, - ProtoMinor: 1, +var defaultTransport = sync.OnceValue(func() http.RoundTripper { + return &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: DialContextWithTimeout(10 * time.Second), // it is good enough in modern days + } +}) + +func DialContextWithTimeout(timeout time.Duration) func(ctx context.Context, network, address string) (net.Conn, error) { + return func(ctx context.Context, network, address string) (net.Conn, error) { + return (&net.Dialer{Timeout: timeout}).DialContext(ctx, network, address) } - return &Request{url, &req, map[string]string{}, defaultSetting, &resp, nil} } -// NewRequest returns *Request with specific method func NewRequest(url, method string) *Request { - return newRequest(url, method) + return &Request{ + url: url, + req: &http.Request{ + Method: method, + Header: make(http.Header), + Proto: "HTTP/1.1", // FIXME: from legacy httplib, it shouldn't be hardcoded + ProtoMajor: 1, + ProtoMinor: 1, + }, + params: map[string]string{}, + + // ATTENTION: from legacy httplib, callers must pay more attention to it, it will cause annoying bugs when the response takes a long time + readWriteTimeout: 60 * time.Second, + } } -// Settings is the default settings for http client -type Settings struct { - UserAgent string - ConnectTimeout time.Duration - ReadWriteTimeout time.Duration - TLSClientConfig *tls.Config - Transport http.RoundTripper -} - -// Request provides more useful methods for requesting one url than http.Request. type Request struct { - url string - req *http.Request - params map[string]string - setting Settings - resp *http.Response - body []byte + url string + req *http.Request + params map[string]string + + readWriteTimeout time.Duration + transport http.RoundTripper } // SetContext sets the request's Context @@ -63,36 +62,24 @@ func (r *Request) SetContext(ctx context.Context) *Request { return r } -// SetTimeout sets connect time out and read-write time out for BeegoRequest. -func (r *Request) SetTimeout(connectTimeout, readWriteTimeout time.Duration) *Request { - r.setting.ConnectTimeout = connectTimeout - r.setting.ReadWriteTimeout = readWriteTimeout +// SetTransport sets the request transport, if not set, will use httplib's default transport with environment proxy support +// ATTENTION: the http.Transport has a connection pool, so it should be reused as much as possible, do not create a lot of transports +func (r *Request) SetTransport(transport http.RoundTripper) *Request { + r.transport = transport return r } func (r *Request) SetReadWriteTimeout(readWriteTimeout time.Duration) *Request { - r.setting.ReadWriteTimeout = readWriteTimeout + r.readWriteTimeout = readWriteTimeout return r } -// SetTLSClientConfig sets tls connection configurations if visiting https url. -func (r *Request) SetTLSClientConfig(config *tls.Config) *Request { - r.setting.TLSClientConfig = config - return r -} - -// Header add header item string in request. +// Header set header item string in request. func (r *Request) Header(key, value string) *Request { r.req.Header.Set(key, value) return r } -// SetTransport sets transport to -func (r *Request) SetTransport(transport http.RoundTripper) *Request { - r.setting.Transport = transport - return r -} - // Param adds query param in to request. // params build query string as ?key1=value1&key2=value2... func (r *Request) Param(key, value string) *Request { @@ -125,11 +112,9 @@ func (r *Request) Body(data any) *Request { return r } -func (r *Request) getResponse() (*http.Response, error) { - if r.resp.StatusCode != 0 { - return r.resp, nil - } - +// Response executes request client and returns the response. +// Caller MUST close the response body if no error occurs. +func (r *Request) Response() (*http.Response, error) { var paramBody string if len(r.params) > 0 { var buf bytes.Buffer @@ -160,59 +145,19 @@ func (r *Request) getResponse() (*http.Response, error) { return nil, err } - trans := r.setting.Transport - if trans == nil { - // create default transport - trans = &http.Transport{ - TLSClientConfig: r.setting.TLSClientConfig, - Proxy: http.ProxyFromEnvironment, - DialContext: TimeoutDialer(r.setting.ConnectTimeout), - } - } else if t, ok := trans.(*http.Transport); ok { - if t.TLSClientConfig == nil { - t.TLSClientConfig = r.setting.TLSClientConfig - } - if t.DialContext == nil { - t.DialContext = TimeoutDialer(r.setting.ConnectTimeout) - } - } - client := &http.Client{ - Transport: trans, - Timeout: r.setting.ReadWriteTimeout, + Transport: r.transport, + Timeout: r.readWriteTimeout, + } + if client.Transport == nil { + client.Transport = defaultTransport() } - if len(r.setting.UserAgent) > 0 && len(r.req.Header.Get("User-Agent")) == 0 { - r.req.Header.Set("User-Agent", r.setting.UserAgent) + if r.req.Header.Get("User-Agent") == "" { + r.req.Header.Set("User-Agent", "GiteaHttpLib") } - resp, err := client.Do(r.req) - if err != nil { - return nil, err - } - r.resp = resp - return resp, nil -} - -// Response executes request client gets response manually. -// Caller MUST close the response body if no error occurs -func (r *Request) Response() (*http.Response, error) { - if r == nil { - return nil, errors.New("invalid request") - } - return r.getResponse() -} - -// TimeoutDialer returns functions of connection dialer with timeout settings for http.Transport Dial field. -func TimeoutDialer(cTimeout time.Duration) func(ctx context.Context, net, addr string) (c net.Conn, err error) { - return func(ctx context.Context, netw, addr string) (net.Conn, error) { - d := net.Dialer{Timeout: cTimeout} - conn, err := d.DialContext(ctx, netw, addr) - if err != nil { - return nil, err - } - return conn, nil - } + return client.Do(r.req) } func (r *Request) GoString() string { diff --git a/modules/httplib/serve.go b/modules/httplib/serve.go index 7c1edf432d1..2d66a86a8b0 100644 --- a/modules/httplib/serve.go +++ b/modules/httplib/serve.go @@ -19,7 +19,6 @@ import ( charsetModule "code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/httpcache" - "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/typesniffer" "code.gitea.io/gitea/modules/util" @@ -109,11 +108,7 @@ func setServeHeadersByFile(r *http.Request, w http.ResponseWriter, mineBuf []byt } if isPlain { - charset, err := charsetModule.DetectEncoding(mineBuf) - if err != nil { - log.Error("Detect raw file %s charset failed: %v, using by default utf-8", opts.Filename, err) - charset = "utf-8" - } + charset, _ := charsetModule.DetectEncoding(mineBuf) opts.ContentTypeCharset = strings.ToLower(charset) } @@ -126,6 +121,7 @@ func setServeHeadersByFile(r *http.Request, w http.ResponseWriter, mineBuf []byt // no sandbox attribute for pdf as it breaks rendering in at least safari. this // should generally be safe as scripts inside PDF can not escape the PDF document // see https://bugs.chromium.org/p/chromium/issues/detail?id=413851 for more discussion + // HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'") } diff --git a/modules/httplib/url.go b/modules/httplib/url.go index 2a1376b8d48..a689c9e98ee 100644 --- a/modules/httplib/url.go +++ b/modules/httplib/url.go @@ -24,7 +24,18 @@ func urlIsRelative(s string, u *url.URL) bool { if len(s) > 1 && (s[0] == '/' || s[0] == '\\') && (s[1] == '/' || s[1] == '\\') { return false } - return u != nil && u.Scheme == "" && u.Host == "" + if u == nil { + return false // invalid URL + } + if u.Scheme != "" || u.Host != "" { + return false // absolute URL with scheme or host + } + // Now, the URL is likely a relative URL + // HINT: GOLANG-HTTP-REDIRECT-BUG: Golang security vulnerability: "http.Redirect" calls "path.Clean" and changes the meaning of a path + // For example, `/a/../\b` will be changed to `/\b`, then it hits the first checked pattern and becomes an open redirect to "{current-scheme}://b" + // For a valid relative URL, its "path" shouldn't contain `\` because such char must be escaped. + // So if the "path" contains `\`, it is not a valid relative URL, then we can prevent open redirect. + return !strings.Contains(u.Path, "\\") } // IsRelativeURL detects if a URL is relative (no scheme or host) @@ -35,14 +46,14 @@ func IsRelativeURL(s string) bool { func getRequestScheme(req *http.Request) string { // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto - if s := req.Header.Get("X-Forwarded-Proto"); s != "" { - return s + if proto, ok := parseForwardedProtoValue(req.Header.Get("X-Forwarded-Proto")); ok { + return proto } - if s := req.Header.Get("X-Forwarded-Protocol"); s != "" { - return s + if proto, ok := parseForwardedProtoValue(req.Header.Get("X-Forwarded-Protocol")); ok { + return proto } - if s := req.Header.Get("X-Url-Scheme"); s != "" { - return s + if proto, ok := parseForwardedProtoValue(req.Header.Get("X-Url-Scheme")); ok { + return proto } if s := req.Header.Get("Front-End-Https"); s != "" { return util.Iif(s == "on", "https", "http") @@ -53,6 +64,13 @@ func getRequestScheme(req *http.Request) string { return "" } +func parseForwardedProtoValue(val string) (string, bool) { + if val == "http" || val == "https" { + return val, true + } + return "", false +} + // GuessCurrentAppURL tries to guess the current full public URL (with sub-path) by http headers. It always has a '/' suffix, exactly the same as setting.AppURL // TODO: should rename it to GuessCurrentPublicURL in the future func GuessCurrentAppURL(ctx context.Context) string { @@ -61,6 +79,10 @@ func GuessCurrentAppURL(ctx context.Context) string { // GuessCurrentHostURL tries to guess the current full host URL (no sub-path) by http headers, there is no trailing slash. func GuessCurrentHostURL(ctx context.Context) string { + // "never" means always trust ROOT_URL and skip any request header detection. + if setting.PublicURLDetection == setting.PublicURLNever { + return strings.TrimSuffix(setting.AppURL, setting.AppSubURL+"/") + } // Try the best guess to get the current host URL (will be used for public URL) by http headers. // At the moment, if site admin doesn't configure the proxy headers correctly, then Gitea would guess wrong. // There are some cases: diff --git a/modules/httplib/url_test.go b/modules/httplib/url_test.go index 0ffb0cac05d..373693693b5 100644 --- a/modules/httplib/url_test.go +++ b/modules/httplib/url_test.go @@ -23,6 +23,7 @@ func TestIsRelativeURL(t *testing.T) { "foo", "/", "/foo?k=%20#abc", + "/foo?k=\\", } for _, s := range rel { assert.True(t, IsRelativeURL(s), "rel = %q", s) @@ -32,6 +33,8 @@ func TestIsRelativeURL(t *testing.T) { "\\\\", "/\\", "\\/", + "/a/../\\b", + "/any\\thing", "mailto:a@b.com", "https://test.com", } @@ -44,6 +47,7 @@ func TestGuessCurrentHostURL(t *testing.T) { defer test.MockVariableValue(&setting.AppURL, "http://cfg-host/sub/")() defer test.MockVariableValue(&setting.AppSubURL, "/sub")() headersWithProto := http.Header{"X-Forwarded-Proto": {"https"}} + maliciousProtoHeaders := http.Header{"X-Forwarded-Proto": {"http://attacker.host/?trash="}} t.Run("Legacy", func(t *testing.T) { defer test.MockVariableValue(&setting.PublicURLDetection, setting.PublicURLLegacy)() @@ -57,6 +61,9 @@ func TestGuessCurrentHostURL(t *testing.T) { // if "X-Forwarded-Proto" exists, then use it and "Host" header ctx = context.WithValue(t.Context(), RequestContextKey, &http.Request{Host: "req-host:3000", Header: headersWithProto}) assert.Equal(t, "https://req-host:3000", GuessCurrentHostURL(ctx)) + + ctx = context.WithValue(t.Context(), RequestContextKey, &http.Request{Host: "req-host:3000", Header: maliciousProtoHeaders}) + assert.Equal(t, "http://cfg-host", GuessCurrentHostURL(ctx)) }) t.Run("Auto", func(t *testing.T) { @@ -73,6 +80,24 @@ func TestGuessCurrentHostURL(t *testing.T) { ctx = context.WithValue(t.Context(), RequestContextKey, &http.Request{Host: "req-host:3000", Header: headersWithProto}) assert.Equal(t, "https://req-host:3000", GuessCurrentHostURL(ctx)) + + ctx = context.WithValue(t.Context(), RequestContextKey, &http.Request{Host: "req-host:3000", Header: maliciousProtoHeaders}) + assert.Equal(t, "http://req-host:3000", GuessCurrentHostURL(ctx)) + }) + + t.Run("Never", func(t *testing.T) { + defer test.MockVariableValue(&setting.PublicURLDetection, setting.PublicURLNever)() + + assert.Equal(t, "http://cfg-host", GuessCurrentHostURL(t.Context())) + + ctx := context.WithValue(t.Context(), RequestContextKey, &http.Request{Host: "req-host:3000"}) + assert.Equal(t, "http://cfg-host", GuessCurrentHostURL(ctx)) + + ctx = context.WithValue(t.Context(), RequestContextKey, &http.Request{Host: "req-host:3000", TLS: &tls.ConnectionState{}}) + assert.Equal(t, "http://cfg-host", GuessCurrentHostURL(ctx)) + + ctx = context.WithValue(t.Context(), RequestContextKey, &http.Request{Host: "req-host:3000", Header: headersWithProto}) + assert.Equal(t, "http://cfg-host", GuessCurrentHostURL(ctx)) }) } diff --git a/modules/indexer/code/bleve/bleve.go b/modules/indexer/code/bleve/bleve.go index c233f491e3e..010ee396606 100644 --- a/modules/indexer/code/bleve/bleve.go +++ b/modules/indexer/code/bleve/bleve.go @@ -4,7 +4,6 @@ package bleve import ( - "bufio" "context" "fmt" "io" @@ -17,6 +16,7 @@ import ( "code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git/gitcmd" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/indexer" path_filter "code.gitea.io/gitea/modules/indexer/code/bleve/token/path" "code.gitea.io/gitea/modules/indexer/code/internal" @@ -150,7 +150,7 @@ func NewIndexer(indexDir string) *Indexer { } } -func (b *Indexer) addUpdate(ctx context.Context, batchWriter git.WriteCloserError, batchReader *bufio.Reader, commitSha string, +func (b *Indexer) addUpdate(ctx context.Context, catFileBatch git.CatFileBatch, commitSha string, update internal.FileUpdate, repo *repo_model.Repository, batch *inner_bleve.FlushingBatch, ) error { // Ignore vendored files in code search @@ -163,7 +163,7 @@ func (b *Indexer) addUpdate(ctx context.Context, batchWriter git.WriteCloserErro var err error if !update.Sized { var stdout string - stdout, _, err = gitcmd.NewCommand("cat-file", "-s").AddDynamicArguments(update.BlobSha).RunStdString(ctx, &gitcmd.RunOpts{Dir: repo.RepoPath()}) + stdout, _, err = gitrepo.RunCmdString(ctx, repo, gitcmd.NewCommand("cat-file", "-s").AddDynamicArguments(update.BlobSha)) if err != nil { return err } @@ -176,16 +176,11 @@ func (b *Indexer) addUpdate(ctx context.Context, batchWriter git.WriteCloserErro return b.addDelete(update.Filename, repo, batch) } - if _, err := batchWriter.Write([]byte(update.BlobSha + "\n")); err != nil { - return err - } - - _, _, size, err = git.ReadBatchLine(batchReader) + info, batchReader, err := catFileBatch.QueryContent(update.BlobSha) if err != nil { return err } - - fileContents, err := io.ReadAll(io.LimitReader(batchReader, size)) + fileContents, err := io.ReadAll(io.LimitReader(batchReader, info.Size)) if err != nil { return err } else if !typesniffer.DetectContentType(fileContents).IsText() { @@ -202,7 +197,7 @@ func (b *Indexer) addUpdate(ctx context.Context, batchWriter git.WriteCloserErro RepoID: repo.ID, CommitID: commitSha, Filename: update.Filename, - Content: string(charset.ToUTF8DropErrors(fileContents, charset.ConvertOpts{})), + Content: string(charset.ToUTF8DropErrors(fileContents)), Language: analyze.GetCodeLanguage(update.Filename, fileContents), UpdatedAt: time.Now().UTC(), }) @@ -217,18 +212,17 @@ func (b *Indexer) addDelete(filename string, repo *repo_model.Repository, batch func (b *Indexer) Index(ctx context.Context, repo *repo_model.Repository, sha string, changes *internal.RepoChanges) error { batch := inner_bleve.NewFlushingBatch(b.inner.Indexer, maxBatchSize) if len(changes.Updates) > 0 { - gitBatch, err := git.NewBatch(ctx, repo.RepoPath()) + catfileBatch, err := gitrepo.NewBatch(ctx, repo) if err != nil { return err } - defer gitBatch.Close() + defer catfileBatch.Close() for _, update := range changes.Updates { - if err := b.addUpdate(ctx, gitBatch.Writer, gitBatch.Reader, sha, update, repo, batch); err != nil { + if err := b.addUpdate(ctx, catfileBatch, sha, update, repo, batch); err != nil { return err } } - gitBatch.Close() } for _, filename := range changes.RemovedFilenames { if err := b.addDelete(filename, repo, batch); err != nil { diff --git a/modules/indexer/code/bleve/token/path/path.go b/modules/indexer/code/bleve/token/path/path.go index 6dfc12f1469..e00bb695e26 100644 --- a/modules/indexer/code/bleve/token/path/path.go +++ b/modules/indexer/code/bleve/token/path/path.go @@ -27,7 +27,7 @@ func TokenFilterConstructor(config map[string]any, cache *registry.Cache) (analy func (s *TokenFilter) Filter(input analysis.TokenStream) analysis.TokenStream { if len(input) == 1 { - // if there is only one token, we dont need to generate the reversed chain + // if there is only one token, we don't need to generate the reversed chain return generatePathTokens(input, false) } diff --git a/modules/indexer/code/elasticsearch/elasticsearch.go b/modules/indexer/code/elasticsearch/elasticsearch.go index b08d837a2a9..9d170528ad3 100644 --- a/modules/indexer/code/elasticsearch/elasticsearch.go +++ b/modules/indexer/code/elasticsearch/elasticsearch.go @@ -4,7 +4,6 @@ package elasticsearch import ( - "bufio" "context" "fmt" "io" @@ -16,6 +15,7 @@ import ( "code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git/gitcmd" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/indexer" "code.gitea.io/gitea/modules/indexer/code/internal" indexer_internal "code.gitea.io/gitea/modules/indexer/internal" @@ -138,7 +138,7 @@ const ( }` ) -func (b *Indexer) addUpdate(ctx context.Context, batchWriter git.WriteCloserError, batchReader *bufio.Reader, sha string, update internal.FileUpdate, repo *repo_model.Repository) ([]elastic.BulkableRequest, error) { +func (b *Indexer) addUpdate(ctx context.Context, catFileBatch git.CatFileBatch, sha string, update internal.FileUpdate, repo *repo_model.Repository) ([]elastic.BulkableRequest, error) { // Ignore vendored files in code search if setting.Indexer.ExcludeVendored && analyze.IsVendor(update.Filename) { return nil, nil @@ -148,7 +148,7 @@ func (b *Indexer) addUpdate(ctx context.Context, batchWriter git.WriteCloserErro var err error if !update.Sized { var stdout string - stdout, _, err = gitcmd.NewCommand("cat-file", "-s").AddDynamicArguments(update.BlobSha).RunStdString(ctx, &gitcmd.RunOpts{Dir: repo.RepoPath()}) + stdout, _, err = gitrepo.RunCmdString(ctx, repo, gitcmd.NewCommand("cat-file", "-s").AddDynamicArguments(update.BlobSha)) if err != nil { return nil, err } @@ -161,16 +161,12 @@ func (b *Indexer) addUpdate(ctx context.Context, batchWriter git.WriteCloserErro return []elastic.BulkableRequest{b.addDelete(update.Filename, repo)}, nil } - if _, err := batchWriter.Write([]byte(update.BlobSha + "\n")); err != nil { - return nil, err - } - - _, _, size, err = git.ReadBatchLine(batchReader) + info, batchReader, err := catFileBatch.QueryContent(update.BlobSha) if err != nil { return nil, err } - fileContents, err := io.ReadAll(io.LimitReader(batchReader, size)) + fileContents, err := io.ReadAll(io.LimitReader(batchReader, info.Size)) if err != nil { return nil, err } else if !typesniffer.DetectContentType(fileContents).IsText() { @@ -190,7 +186,7 @@ func (b *Indexer) addUpdate(ctx context.Context, batchWriter git.WriteCloserErro Doc(map[string]any{ "repo_id": repo.ID, "filename": update.Filename, - "content": string(charset.ToUTF8DropErrors(fileContents, charset.ConvertOpts{})), + "content": string(charset.ToUTF8DropErrors(fileContents)), "commit_id": sha, "language": analyze.GetCodeLanguage(update.Filename, fileContents), "updated_at": timeutil.TimeStampNow(), @@ -209,14 +205,14 @@ func (b *Indexer) addDelete(filename string, repo *repo_model.Repository) elasti func (b *Indexer) Index(ctx context.Context, repo *repo_model.Repository, sha string, changes *internal.RepoChanges) error { reqs := make([]elastic.BulkableRequest, 0) if len(changes.Updates) > 0 { - batch, err := git.NewBatch(ctx, repo.RepoPath()) + batch, err := gitrepo.NewBatch(ctx, repo) if err != nil { return err } defer batch.Close() for _, update := range changes.Updates { - updateReqs, err := b.addUpdate(ctx, batch.Writer, batch.Reader, sha, update, repo) + updateReqs, err := b.addUpdate(ctx, batch, sha, update, repo) if err != nil { return err } @@ -224,7 +220,6 @@ func (b *Indexer) Index(ctx context.Context, repo *repo_model.Repository, sha st reqs = append(reqs, updateReqs...) } } - batch.Close() } for _, filename := range changes.RemovedFilenames { @@ -398,7 +393,7 @@ func (b *Indexer) Search(ctx context.Context, opts *internal.SearchOptions) (int elastic.NewHighlight(). Field("content"). Field("filename"). - NumOfFragments(0). // return all highting content on fragments + NumOfFragments(0). // return all highlighting content on fragments HighlighterType("fvh"), ). Sort("_score", false). @@ -431,7 +426,7 @@ func (b *Indexer) Search(ctx context.Context, opts *internal.SearchOptions) (int elastic.NewHighlight(). Field("content"). Field("filename"). - NumOfFragments(0). // return all highting content on fragments + NumOfFragments(0). // return all highlighting content on fragments HighlighterType("fvh"), ). Sort("_score", false). diff --git a/modules/indexer/code/git.go b/modules/indexer/code/git.go index f1513d66b03..a17b10551d2 100644 --- a/modules/indexer/code/git.go +++ b/modules/indexer/code/git.go @@ -11,13 +11,14 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git/gitcmd" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/indexer/code/internal" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" ) func getDefaultBranchSha(ctx context.Context, repo *repo_model.Repository) (string, error) { - stdout, _, err := gitcmd.NewCommand("show-ref", "-s").AddDynamicArguments(git.BranchPrefix+repo.DefaultBranch).RunStdString(ctx, &gitcmd.RunOpts{Dir: repo.RepoPath()}) + stdout, _, err := gitrepo.RunCmdString(ctx, repo, gitcmd.NewCommand("show-ref", "-s").AddDynamicArguments(git.BranchPrefix+repo.DefaultBranch)) if err != nil { return "", err } @@ -34,7 +35,7 @@ func getRepoChanges(ctx context.Context, repo *repo_model.Repository, revision s needGenesis := len(status.CommitSha) == 0 if !needGenesis { hasAncestorCmd := gitcmd.NewCommand("merge-base").AddDynamicArguments(status.CommitSha, revision) - stdout, _, _ := hasAncestorCmd.RunStdString(ctx, &gitcmd.RunOpts{Dir: repo.RepoPath()}) + stdout, _, _ := gitrepo.RunCmdString(ctx, repo, hasAncestorCmd) // FIXME: error is not handled needGenesis = len(stdout) == 0 } @@ -87,7 +88,7 @@ func parseGitLsTreeOutput(stdout []byte) ([]internal.FileUpdate, error) { // genesisChanges get changes to add repo to the indexer for the first time func genesisChanges(ctx context.Context, repo *repo_model.Repository, revision string) (*internal.RepoChanges, error) { var changes internal.RepoChanges - stdout, _, runErr := gitcmd.NewCommand("ls-tree", "--full-tree", "-l", "-r").AddDynamicArguments(revision).RunStdBytes(ctx, &gitcmd.RunOpts{Dir: repo.RepoPath()}) + stdout, _, runErr := gitrepo.RunCmdBytes(ctx, repo, gitcmd.NewCommand("ls-tree", "--full-tree", "-l", "-r").AddDynamicArguments(revision)) if runErr != nil { return nil, runErr } @@ -100,7 +101,7 @@ func genesisChanges(ctx context.Context, repo *repo_model.Repository, revision s // nonGenesisChanges get changes since the previous indexer update func nonGenesisChanges(ctx context.Context, repo *repo_model.Repository, revision string) (*internal.RepoChanges, error) { diffCmd := gitcmd.NewCommand("diff", "--name-status").AddDynamicArguments(repo.CodeIndexerStatus.CommitSha, revision) - stdout, _, runErr := diffCmd.RunStdString(ctx, &gitcmd.RunOpts{Dir: repo.RepoPath()}) + stdout, _, runErr := gitrepo.RunCmdString(ctx, repo, diffCmd) if runErr != nil { // previous commit sha may have been removed by a force push, so // try rebuilding from scratch @@ -118,7 +119,7 @@ func nonGenesisChanges(ctx context.Context, repo *repo_model.Repository, revisio updateChanges := func() error { cmd := gitcmd.NewCommand("ls-tree", "--full-tree", "-l").AddDynamicArguments(revision). AddDashesAndList(updatedFilenames...) - lsTreeStdout, _, err := cmd.RunStdBytes(ctx, &gitcmd.RunOpts{Dir: repo.RepoPath()}) + lsTreeStdout, _, err := gitrepo.RunCmdBytes(ctx, repo, cmd) if err != nil { return err } diff --git a/modules/indexer/code/gitgrep/gitgrep.go b/modules/indexer/code/gitgrep/gitgrep.go index 6f6e0b47b9e..5fbd7201ef0 100644 --- a/modules/indexer/code/gitgrep/gitgrep.go +++ b/modules/indexer/code/gitgrep/gitgrep.go @@ -24,7 +24,7 @@ func indexSettingToGitGrepPathspecList() (list []string) { return list } -func PerformSearch(ctx context.Context, page int, repoID int64, gitRepo *git.Repository, ref git.RefName, keyword string, searchMode indexer.SearchModeType) (searchResults []*code_indexer.Result, total int, err error) { +func PerformSearch(ctx context.Context, page int, repoID int64, gitRepo *git.Repository, ref git.RefName, keyword string, searchMode indexer.SearchModeType) (searchResults []*code_indexer.Result, total int64, err error) { grepMode := git.GrepModeWords switch searchMode { case indexer.SearchModeExact: @@ -47,7 +47,7 @@ func PerformSearch(ctx context.Context, page int, repoID int64, gitRepo *git.Rep return nil, 0, fmt.Errorf("gitRepo.GetRefCommitID: %w", err) } - total = len(res) + total = int64(len(res)) pageStart := min((page-1)*setting.UI.RepoSearchPagingNum, len(res)) pageEnd := min(page*setting.UI.RepoSearchPagingNum, len(res)) res = res[pageStart:pageEnd] diff --git a/modules/indexer/code/indexer.go b/modules/indexer/code/indexer.go index 6035ddfe95f..98df6944a6b 100644 --- a/modules/indexer/code/indexer.go +++ b/modules/indexer/code/indexer.go @@ -22,6 +22,7 @@ import ( "code.gitea.io/gitea/modules/process" "code.gitea.io/gitea/modules/queue" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" ) var ( @@ -166,12 +167,12 @@ func Init() { log.Fatal("PID: %d Unable to initialize the bleve Repository Indexer at path: %s Error: %v", os.Getpid(), setting.Indexer.RepoPath, err) } case "elasticsearch": - log.Info("PID: %d Initializing Repository Indexer at: %s", os.Getpid(), setting.Indexer.RepoConnStr) + log.Info("PID: %d Initializing Repository Indexer at: %s", os.Getpid(), util.SanitizeCredentialURLs(setting.Indexer.RepoConnStr)) defer func() { if err := recover(); err != nil { log.Error("PANIC whilst initializing repository indexer: %v\nStacktrace: %s", err, log.Stack(2)) log.Error("The indexer files are likely corrupted and may need to be deleted") - log.Error("You can completely remove the \"%s\" index to make Gitea recreate the indexes", setting.Indexer.RepoConnStr) + log.Error("You can completely remove the \"%s\" index to make Gitea recreate the indexes", util.SanitizeCredentialURLs(setting.Indexer.RepoConnStr)) } }() @@ -181,7 +182,7 @@ func Init() { cancel() (*globalIndexer.Load()).Close() close(waitChannel) - log.Fatal("PID: %d Unable to initialize the elasticsearch Repository Indexer connstr: %s Error: %v", os.Getpid(), setting.Indexer.RepoConnStr, err) + log.Fatal("PID: %d Unable to initialize the elasticsearch Repository Indexer connstr: %s Error: %v", os.Getpid(), util.SanitizeCredentialURLs(setting.Indexer.RepoConnStr), err) } default: diff --git a/modules/indexer/code/internal/util.go b/modules/indexer/code/internal/util.go index fa958be473b..5d62a5ccb9d 100644 --- a/modules/indexer/code/internal/util.go +++ b/modules/indexer/code/internal/util.go @@ -17,20 +17,20 @@ func FilenameIndexerID(repoID int64, filename string) string { } func ParseIndexerID(indexerID string) (int64, string) { - index := strings.IndexByte(indexerID, '_') - if index == -1 { + before, after, ok := strings.Cut(indexerID, "_") + if !ok { log.Error("Unexpected ID in repo indexer: %s", indexerID) } - repoID, _ := internal.ParseBase36(indexerID[:index]) - return repoID, indexerID[index+1:] + repoID, _ := internal.ParseBase36(before) + return repoID, after } func FilenameOfIndexerID(indexerID string) string { - index := strings.IndexByte(indexerID, '_') - if index == -1 { + _, after, ok := strings.Cut(indexerID, "_") + if !ok { log.Error("Unexpected ID in repo indexer: %s", indexerID) } - return indexerID[index+1:] + return after } // FilenameMatchIndexPos returns the boundaries of its first seven lines. diff --git a/modules/indexer/code/search.go b/modules/indexer/code/search.go index a7a5d7d2e37..009d659d761 100644 --- a/modules/indexer/code/search.go +++ b/modules/indexer/code/search.go @@ -72,10 +72,11 @@ func writeStrings(buf *bytes.Buffer, strs ...string) error { func HighlightSearchResultCode(filename, language string, lineNums []int, code string) []*ResultLine { // we should highlight the whole code block first, otherwise it doesn't work well with multiple line highlighting - hl, _ := highlight.Code(filename, language, code) + lexer := highlight.DetectChromaLexerByFileName(filename, language) + hl := highlight.RenderCodeByLexer(lexer, code) highlightedLines := strings.Split(string(hl), "\n") - // The lineNums outputted by highlight.Code might not match the original lineNums, because "highlight" removes the last `\n` + // The lineNums outputted by render might not match the original lineNums, because "highlight" removes the last `\n` lines := make([]*ResultLine, min(len(highlightedLines), len(lineNums))) for i := range lines { lines[i] = &ResultLine{ @@ -129,7 +130,7 @@ func searchResult(result *internal.SearchResult, startIndex, endIndex int) (*Res } // PerformSearch perform a search on a repository -func PerformSearch(ctx context.Context, opts *SearchOptions) (int, []*Result, []*SearchResultLanguages, error) { +func PerformSearch(ctx context.Context, opts *SearchOptions) (int64, []*Result, []*SearchResultLanguages, error) { if opts == nil || len(opts.Keyword) == 0 { return 0, nil, nil, nil } @@ -148,5 +149,5 @@ func PerformSearch(ctx context.Context, opts *SearchOptions) (int, []*Result, [] return 0, nil, nil, err } } - return int(total), displayResults, resultLanguages, nil + return total, displayResults, resultLanguages, nil } diff --git a/modules/indexer/issues/elasticsearch/elasticsearch_test.go b/modules/indexer/issues/elasticsearch/elasticsearch_test.go index dc329c07dd9..cb9ed3889d4 100644 --- a/modules/indexer/issues/elasticsearch/elasticsearch_test.go +++ b/modules/indexer/issues/elasticsearch/elasticsearch_test.go @@ -30,7 +30,11 @@ func TestElasticsearchIndexer(t *testing.T) { require.Eventually(t, func() bool { resp, err := http.Get(url) - return err == nil && resp.StatusCode == http.StatusOK + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK }, time.Minute, time.Second, "Expected elasticsearch to be up") indexer := NewIndexer(url, fmt.Sprintf("test_elasticsearch_indexer_%d", time.Now().Unix())) diff --git a/modules/indexer/issues/indexer.go b/modules/indexer/issues/indexer.go index bbc78aecbe5..52b25c1794a 100644 --- a/modules/indexer/issues/indexer.go +++ b/modules/indexer/issues/indexer.go @@ -25,6 +25,7 @@ import ( "code.gitea.io/gitea/modules/process" "code.gitea.io/gitea/modules/queue" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" ) // IndexerMetadata is used to send data to the queue, so it contains only the ids. @@ -100,7 +101,7 @@ func InitIssueIndexer(syncReindex bool) { issueIndexer = elasticsearch.NewIndexer(setting.Indexer.IssueConnStr, setting.Indexer.IssueIndexerName) existed, err = issueIndexer.Init(ctx) if err != nil { - log.Fatal("Unable to issueIndexer.Init with connection %s Error: %v", setting.Indexer.IssueConnStr, err) + log.Fatal("Unable to issueIndexer.Init with connection %s Error: %v", util.SanitizeCredentialURLs(setting.Indexer.IssueConnStr), err) } case "db": issueIndexer = db.GetIndexer() @@ -108,7 +109,7 @@ func InitIssueIndexer(syncReindex bool) { issueIndexer = meilisearch.NewIndexer(setting.Indexer.IssueConnStr, setting.Indexer.IssueConnAuth, setting.Indexer.IssueIndexerName) existed, err = issueIndexer.Init(ctx) if err != nil { - log.Fatal("Unable to issueIndexer.Init with connection %s Error: %v", setting.Indexer.IssueConnStr, err) + log.Fatal("Unable to issueIndexer.Init with connection %s Error: %v", util.SanitizeCredentialURLs(setting.Indexer.IssueConnStr), err) } default: log.Fatal("Unknown issue indexer type: %s", setting.Indexer.IssueType) diff --git a/modules/indexer/issues/meilisearch/meilisearch.go b/modules/indexer/issues/meilisearch/meilisearch.go index b7fae6ee9a0..5715cf47947 100644 --- a/modules/indexer/issues/meilisearch/meilisearch.go +++ b/modules/indexer/issues/meilisearch/meilisearch.go @@ -124,7 +124,7 @@ func (b *Indexer) Delete(_ context.Context, ids ...int64) error { } for _, id := range ids { - _, err := b.inner.Client.Index(b.inner.VersionedIndexName()).DeleteDocument(strconv.FormatInt(id, 10)) + _, err := b.inner.Client.Index(b.inner.VersionedIndexName()).DeleteDocument(strconv.FormatInt(id, 10), nil) if err != nil { return err } diff --git a/modules/indexer/issues/meilisearch/meilisearch_test.go b/modules/indexer/issues/meilisearch/meilisearch_test.go index a32cbdd6de1..81a27487bb8 100644 --- a/modules/indexer/issues/meilisearch/meilisearch_test.go +++ b/modules/indexer/issues/meilisearch/meilisearch_test.go @@ -36,7 +36,11 @@ func TestMeilisearchIndexer(t *testing.T) { require.Eventually(t, func() bool { resp, err := http.Get(url) - return err == nil && resp.StatusCode == http.StatusOK + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK }, time.Minute, time.Second, "Expected meilisearch to be up") indexer := NewIndexer(url, key, fmt.Sprintf("test_meilisearch_indexer_%d", time.Now().Unix())) diff --git a/modules/issue/template/unmarshal.go b/modules/issue/template/unmarshal.go index 1d8e9dd02d9..ceab6babf4c 100644 --- a/modules/issue/template/unmarshal.go +++ b/modules/issue/template/unmarshal.go @@ -5,7 +5,6 @@ package template import ( "fmt" - "io" "path" "strconv" @@ -76,7 +75,7 @@ func unmarshalFromEntry(entry *git.TreeEntry, filename string) (*api.IssueTempla } defer r.Close() - content, err := io.ReadAll(r) + content, err := util.ReadWithLimit(r, 1024*1024) if err != nil { return nil, fmt.Errorf("read all: %w", err) } diff --git a/modules/json/json.go b/modules/json/json.go index 444dc8526ae..d053f91cf78 100644 --- a/modules/json/json.go +++ b/modules/json/json.go @@ -8,8 +8,6 @@ import ( "encoding/binary" "encoding/json" //nolint:depguard // this package wraps it "io" - - jsoniter "github.com/json-iterator/go" ) // Encoder represents an encoder for json @@ -31,71 +29,7 @@ type Interface interface { Indent(dst *bytes.Buffer, src []byte, prefix, indent string) error } -var ( - // DefaultJSONHandler default json handler - DefaultJSONHandler Interface = JSONiter{jsoniter.ConfigCompatibleWithStandardLibrary} - - _ Interface = StdJSON{} - _ Interface = JSONiter{} -) - -// StdJSON implements Interface via encoding/json -type StdJSON struct{} - -// Marshal implements Interface -func (StdJSON) Marshal(v any) ([]byte, error) { - return json.Marshal(v) -} - -// Unmarshal implements Interface -func (StdJSON) Unmarshal(data []byte, v any) error { - return json.Unmarshal(data, v) -} - -// NewEncoder implements Interface -func (StdJSON) NewEncoder(writer io.Writer) Encoder { - return json.NewEncoder(writer) -} - -// NewDecoder implements Interface -func (StdJSON) NewDecoder(reader io.Reader) Decoder { - return json.NewDecoder(reader) -} - -// Indent implements Interface -func (StdJSON) Indent(dst *bytes.Buffer, src []byte, prefix, indent string) error { - return json.Indent(dst, src, prefix, indent) -} - -// JSONiter implements Interface via jsoniter -type JSONiter struct { - jsoniter.API -} - -// Marshal implements Interface -func (j JSONiter) Marshal(v any) ([]byte, error) { - return j.API.Marshal(v) -} - -// Unmarshal implements Interface -func (j JSONiter) Unmarshal(data []byte, v any) error { - return j.API.Unmarshal(data, v) -} - -// NewEncoder implements Interface -func (j JSONiter) NewEncoder(writer io.Writer) Encoder { - return j.API.NewEncoder(writer) -} - -// NewDecoder implements Interface -func (j JSONiter) NewDecoder(reader io.Reader) Decoder { - return j.API.NewDecoder(reader) -} - -// Indent implements Interface, since jsoniter don't support Indent, just use encoding/json's -func (j JSONiter) Indent(dst *bytes.Buffer, src []byte, prefix, indent string) error { - return json.Indent(dst, src, prefix, indent) -} +var DefaultJSONHandler = getDefaultJSONHandler() // Marshal converts object as bytes func Marshal(v any) ([]byte, error) { diff --git a/modules/json/json_test.go b/modules/json/json_test.go index ace71679134..2fa4da4cf79 100644 --- a/modules/json/json_test.go +++ b/modules/json/json_test.go @@ -4,6 +4,7 @@ package json import ( + "bytes" "testing" "github.com/stretchr/testify/assert" @@ -16,3 +17,12 @@ func TestGiteaDBJSONUnmarshal(t *testing.T) { err = UnmarshalHandleDoubleEncode([]byte(""), &m) assert.NoError(t, err) } + +func TestIndent(t *testing.T) { + buf := &bytes.Buffer{} + err := Indent(buf, []byte(`{"a":1}`), ">", " ") + assert.NoError(t, err) + assert.Equal(t, `{ +> "a": 1 +>}`, buf.String()) +} diff --git a/modules/json/jsongoccy.go b/modules/json/jsongoccy.go new file mode 100644 index 00000000000..77ea047fa71 --- /dev/null +++ b/modules/json/jsongoccy.go @@ -0,0 +1,35 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package json + +import ( + "bytes" + "io" + + "github.com/goccy/go-json" +) + +var _ Interface = jsonGoccy{} + +type jsonGoccy struct{} + +func (jsonGoccy) Marshal(v any) ([]byte, error) { + return json.Marshal(v) +} + +func (jsonGoccy) Unmarshal(data []byte, v any) error { + return json.Unmarshal(data, v) +} + +func (jsonGoccy) NewEncoder(writer io.Writer) Encoder { + return json.NewEncoder(writer) +} + +func (jsonGoccy) NewDecoder(reader io.Reader) Decoder { + return json.NewDecoder(reader) +} + +func (jsonGoccy) Indent(dst *bytes.Buffer, src []byte, prefix, indent string) error { + return json.Indent(dst, src, prefix, indent) +} diff --git a/modules/json/jsonlegacy.go b/modules/json/jsonlegacy.go new file mode 100644 index 00000000000..156e4560418 --- /dev/null +++ b/modules/json/jsonlegacy.go @@ -0,0 +1,22 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build !goexperiment.jsonv2 + +package json + +import ( + "io" +) + +func getDefaultJSONHandler() Interface { + return jsonGoccy{} +} + +func MarshalKeepOptionalEmpty(v any) ([]byte, error) { + return DefaultJSONHandler.Marshal(v) +} + +func NewDecoderCaseInsensitive(reader io.Reader) Decoder { + return DefaultJSONHandler.NewDecoder(reader) +} diff --git a/modules/json/jsonv1.go b/modules/json/jsonv1.go new file mode 100644 index 00000000000..55ec4736b9f --- /dev/null +++ b/modules/json/jsonv1.go @@ -0,0 +1,34 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package json + +import ( + "bytes" + "encoding/json" //nolint:depguard // this package wraps it + "io" +) + +type jsonV1 struct{} + +var _ Interface = jsonV1{} + +func (jsonV1) Marshal(v any) ([]byte, error) { + return json.Marshal(v) +} + +func (jsonV1) Unmarshal(data []byte, v any) error { + return json.Unmarshal(data, v) +} + +func (jsonV1) NewEncoder(writer io.Writer) Encoder { + return json.NewEncoder(writer) +} + +func (jsonV1) NewDecoder(reader io.Reader) Decoder { + return json.NewDecoder(reader) +} + +func (jsonV1) Indent(dst *bytes.Buffer, src []byte, prefix, indent string) error { + return json.Indent(dst, src, prefix, indent) +} diff --git a/modules/json/jsonv2.go b/modules/json/jsonv2.go new file mode 100644 index 00000000000..0bba2783bcb --- /dev/null +++ b/modules/json/jsonv2.go @@ -0,0 +1,92 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build goexperiment.jsonv2 + +package json + +import ( + "bytes" + jsonv1 "encoding/json" //nolint:depguard // this package wraps it + jsonv2 "encoding/json/v2" //nolint:depguard // this package wraps it + "io" +) + +// JSONv2 implements Interface via encoding/json/v2 +// Requires GOEXPERIMENT=jsonv2 to be set at build time +type JSONv2 struct { + marshalOptions jsonv2.Options + marshalKeepOptionalEmptyOptions jsonv2.Options + unmarshalOptions jsonv2.Options + unmarshalCaseInsensitiveOptions jsonv2.Options +} + +var jsonV2 JSONv2 + +func init() { + commonMarshalOptions := []jsonv2.Options{ + jsonv2.FormatNilSliceAsNull(true), + jsonv2.FormatNilMapAsNull(true), + } + jsonV2.marshalOptions = jsonv2.JoinOptions(commonMarshalOptions...) + jsonV2.unmarshalOptions = jsonv2.DefaultOptionsV2() + + // By default, "json/v2" omitempty removes all `""` empty strings, no matter where it comes from. + // v1 has a different behavior: if the `""` is from a null pointer, or a Marshal function, it is kept. + // Golang issue: https://github.com/golang/go/issues/75623 encoding/json/v2: unable to make omitempty work with pointer or Optional type with goexperiment.jsonv2 + jsonV2.marshalKeepOptionalEmptyOptions = jsonv2.JoinOptions(append(commonMarshalOptions, jsonv1.OmitEmptyWithLegacySemantics(true))...) + + // Some legacy code uses case-insensitive matching (for example: parsing oci.ImageConfig) + jsonV2.unmarshalCaseInsensitiveOptions = jsonv2.JoinOptions(jsonv2.MatchCaseInsensitiveNames(true)) +} + +func getDefaultJSONHandler() Interface { + return &jsonV2 +} + +func MarshalKeepOptionalEmpty(v any) ([]byte, error) { + return jsonv2.Marshal(v, jsonV2.marshalKeepOptionalEmptyOptions) +} + +func (j *JSONv2) Marshal(v any) ([]byte, error) { + return jsonv2.Marshal(v, j.marshalOptions) +} + +func (j *JSONv2) Unmarshal(data []byte, v any) error { + return jsonv2.Unmarshal(data, v, j.unmarshalOptions) +} + +func (j *JSONv2) NewEncoder(writer io.Writer) Encoder { + return &jsonV2Encoder{writer: writer, opts: j.marshalOptions} +} + +func (j *JSONv2) NewDecoder(reader io.Reader) Decoder { + return &jsonV2Decoder{reader: reader, opts: j.unmarshalOptions} +} + +// Indent implements Interface using standard library (JSON v2 doesn't have Indent yet) +func (*JSONv2) Indent(dst *bytes.Buffer, src []byte, prefix, indent string) error { + return jsonv1.Indent(dst, src, prefix, indent) +} + +type jsonV2Encoder struct { + writer io.Writer + opts jsonv2.Options +} + +func (e *jsonV2Encoder) Encode(v any) error { + return jsonv2.MarshalWrite(e.writer, v, e.opts) +} + +type jsonV2Decoder struct { + reader io.Reader + opts jsonv2.Options +} + +func (d *jsonV2Decoder) Decode(v any) error { + return jsonv2.UnmarshalRead(d.reader, v, d.opts) +} + +func NewDecoderCaseInsensitive(reader io.Reader) Decoder { + return &jsonV2Decoder{reader: reader, opts: jsonV2.unmarshalCaseInsensitiveOptions} +} diff --git a/modules/lfs/http_client_test.go b/modules/lfs/http_client_test.go index 179bcdb29a4..3e5ee9ec603 100644 --- a/modules/lfs/http_client_test.go +++ b/modules/lfs/http_client_test.go @@ -193,7 +193,7 @@ func TestHTTPClientDownload(t *testing.T) { }, { endpoint: "https://invalid-json-response.io", - expectedError: "invalid json", + expectedError: "/(invalid json|invalid character)/", }, { endpoint: "https://valid-batch-request-download.io", @@ -258,7 +258,11 @@ func TestHTTPClientDownload(t *testing.T) { return nil }) if c.expectedError != "" { - assert.ErrorContains(t, err, c.expectedError) + if strings.HasPrefix(c.expectedError, "/") && strings.HasSuffix(c.expectedError, "/") { + assert.Regexp(t, strings.Trim(c.expectedError, "/"), err.Error()) + } else { + assert.ErrorContains(t, err, c.expectedError) + } } else { assert.NoError(t, err) } @@ -297,7 +301,7 @@ func TestHTTPClientUpload(t *testing.T) { }, { endpoint: "https://invalid-json-response.io", - expectedError: "invalid json", + expectedError: "/(invalid json|invalid character)/", }, { endpoint: "https://valid-batch-request-upload.io", @@ -352,7 +356,11 @@ func TestHTTPClientUpload(t *testing.T) { return io.NopCloser(new(bytes.Buffer)), objectError }) if c.expectedError != "" { - assert.ErrorContains(t, err, c.expectedError) + if strings.HasPrefix(c.expectedError, "/") && strings.HasSuffix(c.expectedError, "/") { + assert.Regexp(t, strings.Trim(c.expectedError, "/"), err.Error()) + } else { + assert.ErrorContains(t, err, c.expectedError) + } } else { assert.NoError(t, err) } diff --git a/modules/lfs/pointer_scanner_gogit.go b/modules/lfs/pointer_scanner_gogit.go index e153b8e24e5..ccfb16b6c0e 100644 --- a/modules/lfs/pointer_scanner_gogit.go +++ b/modules/lfs/pointer_scanner_gogit.go @@ -15,7 +15,7 @@ import ( ) // SearchPointerBlobs scans the whole repository for LFS pointer files -func SearchPointerBlobs(ctx context.Context, repo *git.Repository, pointerChan chan<- PointerBlob, errChan chan<- error) { +func SearchPointerBlobs(ctx context.Context, repo *git.Repository, pointerChan chan<- PointerBlob) error { gitRepo := repo.GoGitRepo() err := func() error { @@ -49,14 +49,7 @@ func SearchPointerBlobs(ctx context.Context, repo *git.Repository, pointerChan c return nil }) }() - if err != nil { - select { - case <-ctx.Done(): - default: - errChan <- err - } - } close(pointerChan) - close(errChan) + return err } diff --git a/modules/lfs/pointer_scanner_nogogit.go b/modules/lfs/pointer_scanner_nogogit.go index c37a93e73bf..29f5d0e3460 100644 --- a/modules/lfs/pointer_scanner_nogogit.go +++ b/modules/lfs/pointer_scanner_nogogit.go @@ -8,96 +8,84 @@ package lfs import ( "bufio" "context" + "errors" "io" "strconv" "strings" - "sync" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/git/gitcmd" "code.gitea.io/gitea/modules/git/pipeline" + "code.gitea.io/gitea/modules/util" + + "golang.org/x/sync/errgroup" ) // SearchPointerBlobs scans the whole repository for LFS pointer files -func SearchPointerBlobs(ctx context.Context, repo *git.Repository, pointerChan chan<- PointerBlob, errChan chan<- error) { - basePath := repo.Path +func SearchPointerBlobs(ctx context.Context, repo *git.Repository, pointerChan chan<- PointerBlob) error { + cmd1AllObjs, cmd3BatchContent := gitcmd.NewCommand(), gitcmd.NewCommand() - catFileCheckReader, catFileCheckWriter := io.Pipe() - shasToBatchReader, shasToBatchWriter := io.Pipe() - catFileBatchReader, catFileBatchWriter := io.Pipe() + cmd1AllObjsStdout, cmd1AllObjsStdoutClose := cmd1AllObjs.MakeStdoutPipe() + defer cmd1AllObjsStdoutClose() - wg := sync.WaitGroup{} - wg.Add(4) - - // Create the go-routines in reverse order. + cmd3BatchContentIn, cmd3BatchContentOut, cmd3BatchContentClose := cmd3BatchContent.MakeStdinStdoutPipe() + defer cmd3BatchContentClose() + // Create the go-routines in reverse order (update: the order is not needed any more, the pipes are properly prepared) + wg := errgroup.Group{} // 4. Take the output of cat-file --batch and check if each file in turn // to see if they're pointers to files in the LFS store - go createPointerResultsFromCatFileBatch(ctx, catFileBatchReader, &wg, pointerChan) + wg.Go(func() error { + return createPointerResultsFromCatFileBatch(cmd3BatchContentOut, pointerChan) + }) // 3. Take the shas of the blobs and batch read them - go pipeline.CatFileBatch(ctx, shasToBatchReader, catFileBatchWriter, &wg, basePath) + wg.Go(func() error { + return pipeline.CatFileBatch(ctx, cmd3BatchContent, repo.Path) + }) // 2. From the provided objects restrict to blobs <=1k - go pipeline.BlobsLessThan1024FromCatFileBatchCheck(catFileCheckReader, shasToBatchWriter, &wg) + wg.Go(func() error { + return pipeline.BlobsLessThan1024FromCatFileBatchCheck(cmd1AllObjsStdout, cmd3BatchContentIn) + }) // 1. Run batch-check on all objects in the repository - if !git.DefaultFeatures().CheckVersionAtLeast("2.6.0") { - revListReader, revListWriter := io.Pipe() - shasToCheckReader, shasToCheckWriter := io.Pipe() - wg.Add(2) - go pipeline.CatFileBatchCheck(ctx, shasToCheckReader, catFileCheckWriter, &wg, basePath) - go pipeline.BlobsFromRevListObjects(revListReader, shasToCheckWriter, &wg) - go pipeline.RevListAllObjects(ctx, revListWriter, &wg, basePath, errChan) - } else { - go pipeline.CatFileBatchCheckAllObjects(ctx, catFileCheckWriter, &wg, basePath, errChan) - } - wg.Wait() - + wg.Go(func() error { + return pipeline.CatFileBatchCheckAllObjects(ctx, cmd1AllObjs, repo.Path) + }) + err := wg.Wait() close(pointerChan) - close(errChan) + return err } -func createPointerResultsFromCatFileBatch(ctx context.Context, catFileBatchReader *io.PipeReader, wg *sync.WaitGroup, pointerChan chan<- PointerBlob) { - defer wg.Done() +func createPointerResultsFromCatFileBatch(catFileBatchReader io.ReadCloser, pointerChan chan<- PointerBlob) error { defer catFileBatchReader.Close() bufferedReader := bufio.NewReader(catFileBatchReader) buf := make([]byte, 1025) -loop: for { - select { - case <-ctx.Done(): - break loop - default: - } - // File descriptor line: sha sha, err := bufferedReader.ReadString(' ') if err != nil { - _ = catFileBatchReader.CloseWithError(err) - break + return util.Iif(errors.Is(err, io.EOF), nil, err) } sha = strings.TrimSpace(sha) // Throw away the blob if _, err := bufferedReader.ReadString(' '); err != nil { - _ = catFileBatchReader.CloseWithError(err) - break + return err } sizeStr, err := bufferedReader.ReadString('\n') if err != nil { - _ = catFileBatchReader.CloseWithError(err) - break + return err } size, err := strconv.Atoi(sizeStr[:len(sizeStr)-1]) if err != nil { - _ = catFileBatchReader.CloseWithError(err) - break + return err } pointerBuf := buf[:size+1] if _, err := io.ReadFull(bufferedReader, pointerBuf); err != nil { - _ = catFileBatchReader.CloseWithError(err) - break + return err } pointerBuf = pointerBuf[:size] // Now we need to check if the pointerBuf is an LFS pointer @@ -105,7 +93,6 @@ loop: if !pointer.IsValid() { continue } - pointerChan <- PointerBlob{Hash: sha, Pointer: pointer} } } diff --git a/modules/lfs/shared.go b/modules/lfs/shared.go index cd9488e3dbf..e04c089e513 100644 --- a/modules/lfs/shared.go +++ b/modules/lfs/shared.go @@ -66,6 +66,21 @@ type Link struct { ExpiresAt *time.Time `json:"expires_at,omitempty"` } +func NewLink(href string) *Link { + return &Link{Href: href} +} + +func (l *Link) WithHeader(k, v string) *Link { + if v == "" { + return l + } + if l.Header == nil { + l.Header = make(map[string]string) + } + l.Header[k] = v + return l +} + // ObjectError defines the JSON structure returned to the client in case of an error. type ObjectError struct { Code int `json:"code"` diff --git a/modules/lfstransfer/backend/backend.go b/modules/lfstransfer/backend/backend.go index dd4108ea564..92f08eac136 100644 --- a/modules/lfstransfer/backend/backend.go +++ b/modules/lfstransfer/backend/backend.go @@ -95,7 +95,7 @@ func (g *GiteaBackend) Batch(_ string, pointers []transfer.BatchItem, args trans var respBody lfs.BatchResponse err = json.Unmarshal(respBytes, &respBody) if err != nil { - g.logger.Log("json umarshal error", err) + g.logger.Log("json unmarshal error", err) return nil, err } @@ -157,7 +157,7 @@ func (g *GiteaBackend) Batch(_ string, pointers []transfer.BatchItem, args trans } // Download implements transfer.Backend. The returned reader must be closed by the caller. -func (g *GiteaBackend) Download(oid string, args transfer.Args) (io.ReadCloser, int64, error) { +func (g *GiteaBackend) Download(oid string, args transfer.Args) (_ io.ReadCloser, _ int64, retErr error) { idMapStr, exists := args[argID] if !exists { return nil, 0, ErrMissingID @@ -188,7 +188,15 @@ func (g *GiteaBackend) Download(oid string, args transfer.Args) (io.ReadCloser, if err != nil { return nil, 0, fmt.Errorf("failed to get response: %w", err) } - // no need to close the body here by "defer resp.Body.Close()", see below + // We must return the ReaderCloser but not "ReadAll", to avoid OOM. + // "transfer.Backend" will check io.Closer interface and close the Body reader. + // So only close the Body when error occurs + defer func() { + if retErr != nil { + _ = resp.Body.Close() + } + }() + if resp.StatusCode != http.StatusOK { return nil, 0, statusCodeToErr(resp.StatusCode) } @@ -197,7 +205,6 @@ func (g *GiteaBackend) Download(oid string, args transfer.Args) (io.ReadCloser, if err != nil { return nil, 0, fmt.Errorf("failed to parse content length: %w", err) } - // transfer.Backend will check io.Closer interface and close this Body reader return resp.Body, respSize, nil } diff --git a/modules/lfstransfer/backend/lock.go b/modules/lfstransfer/backend/lock.go index 2c3c16a9bb0..5bfa3a89c30 100644 --- a/modules/lfstransfer/backend/lock.go +++ b/modules/lfstransfer/backend/lock.go @@ -69,7 +69,7 @@ func (g *giteaLockBackend) Create(path, refname string) (transfer.Lock, error) { var respBody lfslock.LFSLockResponse err = json.Unmarshal(respBytes, &respBody) if err != nil { - g.logger.Log("json umarshal error", err) + g.logger.Log("json unmarshal error", err) return nil, err } @@ -202,7 +202,7 @@ func (g *giteaLockBackend) queryLocks(v url.Values) ([]transfer.Lock, string, er var respBody lfslock.LFSLockList err = json.Unmarshal(respBytes, &respBody) if err != nil { - g.logger.Log("json umarshal error", err) + g.logger.Log("json unmarshal error", err) return nil, "", err } diff --git a/modules/lfstransfer/logger.go b/modules/lfstransfer/logger.go index 517c2d9ba13..dfb9c0bb660 100644 --- a/modules/lfstransfer/logger.go +++ b/modules/lfstransfer/logger.go @@ -17,5 +17,5 @@ func newLogger() transfer.Logger { } // Log implements transfer.Logger -func (g *GiteaLogger) Log(msg string, itms ...any) { +func (g *GiteaLogger) Log(msg string, items ...any) { } diff --git a/modules/log/init.go b/modules/log/init.go index 3fb5200ad70..ccaab50de32 100644 --- a/modules/log/init.go +++ b/modules/log/init.go @@ -35,10 +35,10 @@ func init() { } } -func newProcessTypedContext(parent context.Context, desc string) (ctx context.Context, cancel context.CancelFunc) { +func newProcessTypedContext(parent context.Context, desc string) (context.Context, context.CancelFunc) { // the "process manager" also calls "log.Trace()" to output logs, so if we want to create new contexts by the manager, we need to disable the trace temporarily process.TraceLogDisable(true) defer process.TraceLogDisable(false) - ctx, _, cancel = process.GetManager().AddTypedContext(parent, desc, process.SystemProcessType, false) - return ctx, cancel + ctx, _, finished := process.GetManager().AddTypedContext(parent, desc, process.SystemProcessType, false) + return ctx, context.CancelFunc(finished) } diff --git a/modules/log/logger_global.go b/modules/log/logger_global.go index 07c25cd62f6..2bc8c4f4497 100644 --- a/modules/log/logger_global.go +++ b/modules/log/logger_global.go @@ -18,6 +18,7 @@ func GetLevel() Level { } func Log(skip int, level Level, format string, v ...any) { + // codeql[disable-next-line=go/clear-text-logging] GetLogger(DEFAULT).Log(skip+1, &Event{Level: level}, format, v...) } diff --git a/modules/log/misc.go b/modules/log/misc.go index c9d230e4ac3..a58b3757da1 100644 --- a/modules/log/misc.go +++ b/modules/log/misc.go @@ -20,6 +20,7 @@ func BaseLoggerToGeneralLogger(b BaseLogger) Logger { var _ Logger = (*baseToLogger)(nil) func (s *baseToLogger) Log(skip int, event *Event, format string, v ...any) { + // codeql[disable-next-line=go/clear-text-logging] s.base.Log(skip+1, event, format, v...) } diff --git a/modules/markup/asciicast/asciicast.go b/modules/markup/asciicast/asciicast.go index d86d61d7c4c..b3af5eef091 100644 --- a/modules/markup/asciicast/asciicast.go +++ b/modules/markup/asciicast/asciicast.go @@ -20,14 +20,12 @@ func init() { // See https://github.com/asciinema/asciinema/blob/develop/doc/asciicast-v2.md type Renderer struct{} -// Name implements markup.Renderer func (Renderer) Name() string { return "asciicast" } -// Extensions implements markup.Renderer -func (Renderer) Extensions() []string { - return []string{".cast"} +func (Renderer) FileNamePatterns() []string { + return []string{"*.cast"} } const ( @@ -35,12 +33,10 @@ const ( playerSrcAttr = "data-asciinema-player-src" ) -// SanitizerRules implements markup.Renderer func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule { return []setting.MarkupSanitizerRule{{Element: "div", AllowAttr: playerSrcAttr}} } -// Render implements markup.Renderer func (Renderer) Render(ctx *markup.RenderContext, _ io.Reader, output io.Writer) error { rawURL := fmt.Sprintf("%s/%s/%s/raw/%s/%s", setting.AppSubURL, diff --git a/modules/markup/camo.go b/modules/markup/camo.go index 7e2583469d3..f07d62d4f91 100644 --- a/modules/markup/camo.go +++ b/modules/markup/camo.go @@ -11,7 +11,6 @@ import ( "strings" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" ) // CamoEncode encodes a lnk to fit with the go-camo and camo proxy links. The purposes of camo-proxy are: @@ -27,7 +26,7 @@ func CamoEncode(link string) string { macSum := b64encode(mac.Sum(nil)) encodedURL := b64encode([]byte(link)) - return util.URLJoin(setting.Camo.ServerURL, macSum, encodedURL) + return strings.TrimSuffix(setting.Camo.ServerURL, "/") + "/" + macSum + "/" + encodedURL } func b64encode(data []byte) string { diff --git a/modules/markup/common/footnote.go b/modules/markup/common/footnote.go index 1ece436c662..e552a282378 100644 --- a/modules/markup/common/footnote.go +++ b/modules/markup/common/footnote.go @@ -405,9 +405,9 @@ func (r *FootnoteHTMLRenderer) renderFootnoteLink(w util.BufWriter, source []byt if entering { n := node.(*FootnoteLink) is := strconv.Itoa(n.Index) - _, _ = w.WriteString(``) // FIXME: here and below, need to keep the classes _, _ = w.WriteString(is) @@ -419,7 +419,7 @@ func (r *FootnoteHTMLRenderer) renderFootnoteLink(w util.BufWriter, source []byt func (r *FootnoteHTMLRenderer) renderFootnoteBackLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if entering { n := node.(*FootnoteBackLink) - _, _ = w.WriteString(` `) _, _ = w.WriteString("↩︎") @@ -431,7 +431,7 @@ func (r *FootnoteHTMLRenderer) renderFootnoteBackLink(w util.BufWriter, source [ func (r *FootnoteHTMLRenderer) renderFootnote(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { n := node.(*Footnote) if entering { - _, _ = w.WriteString(`
  • \^{}\[\]` + "`" + `]*)?`) + v.wwwURLRegexp = regexp.MustCompile(`^www\.[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}((?:/|[#?])[-a-zA-Z0-9@:%_\+.~#!?&//=\(\);,'">\^{}\[\]` + "`" + `]*)?`) v.LinkRegex, _ = xurls.StrictMatchingScheme("https?://") return v }) @@ -75,7 +75,7 @@ func (s *linkifyParser) Parse(parent ast.Node, block text.Reader, pc parser.Cont m = GlobalVars().LinkRegex.FindSubmatchIndex(line) } if m == nil && bytes.HasPrefix(line, domainWWW) { - m = GlobalVars().wwwURLRegxp.FindSubmatchIndex(line) + m = GlobalVars().wwwURLRegexp.FindSubmatchIndex(line) protocol = []byte("http") } if m != nil { @@ -96,6 +96,7 @@ func (s *linkifyParser) Parse(parent ast.Node, block text.Reader, pc parser.Cont m[1] -= closing } } else if lastChar == ';' { + // exclude HTML entity reference, e.g.: exclude " " from "http://example.com?foo=1 " i := m[1] - 2 for ; i >= m[0]; i-- { if util.IsAlphaNumeric(line[i]) { @@ -105,7 +106,7 @@ func (s *linkifyParser) Parse(parent ast.Node, block text.Reader, pc parser.Cont } if i != m[1]-2 { if line[i] == '&' { - m[1] -= m[1] - i + m[1] = i } } } diff --git a/modules/markup/console/console.go b/modules/markup/console/console.go index 492579b0a50..bcd6cfce77e 100644 --- a/modules/markup/console/console.go +++ b/modules/markup/console/console.go @@ -20,29 +20,24 @@ func init() { markup.RegisterRenderer(Renderer{}) } -// Renderer implements markup.Renderer type Renderer struct{} var _ markup.RendererContentDetector = (*Renderer)(nil) -// Name implements markup.Renderer func (Renderer) Name() string { return "console" } -// Extensions implements markup.Renderer -func (Renderer) Extensions() []string { - return []string{".sh-session"} +func (Renderer) FileNamePatterns() []string { + return []string{"*.sh-session"} } -// SanitizerRules implements markup.Renderer func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule { return []setting.MarkupSanitizerRule{ {Element: "span", AllowAttr: "class", Regexp: `^term-((fg[ix]?|bg)\d+|container)$`}, } } -// CanRender implements markup.RendererContentDetector func (Renderer) CanRender(filename string, sniffedType typesniffer.SniffedType, prefetchBuf []byte) bool { if !sniffedType.IsTextPlain() { return false diff --git a/modules/markup/csv/csv.go b/modules/markup/csv/csv.go index 61977d458ae..910a1518bc5 100644 --- a/modules/markup/csv/csv.go +++ b/modules/markup/csv/csv.go @@ -20,20 +20,16 @@ func init() { markup.RegisterRenderer(Renderer{}) } -// Renderer implements markup.Renderer for csv files type Renderer struct{} -// Name implements markup.Renderer func (Renderer) Name() string { return "csv" } -// Extensions implements markup.Renderer -func (Renderer) Extensions() []string { - return []string{".csv", ".tsv"} +func (Renderer) FileNamePatterns() []string { + return []string{"*.csv", "*.tsv"} } -// SanitizerRules implements markup.Renderer func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule { return []setting.MarkupSanitizerRule{ {Element: "table", AllowAttr: "class", Regexp: `^data-table$`}, diff --git a/modules/markup/external/external.go b/modules/markup/external/external.go index 39861ade121..4d447e301ab 100644 --- a/modules/markup/external/external.go +++ b/modules/markup/external/external.go @@ -15,14 +15,15 @@ import ( "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/process" "code.gitea.io/gitea/modules/setting" + + "github.com/kballard/go-shellquote" ) // RegisterRenderers registers all supported third part renderers according settings func RegisterRenderers() { + markup.RegisterRenderer(&openAPIRenderer{}) for _, renderer := range setting.ExternalMarkupRenderers { - if renderer.Enabled && renderer.Command != "" && len(renderer.FileExtensions) > 0 { - markup.RegisterRenderer(&Renderer{renderer}) - } + markup.RegisterRenderer(&Renderer{renderer}) } } @@ -36,34 +37,27 @@ var ( _ markup.ExternalRenderer = (*Renderer)(nil) ) -// Name returns the external tool name func (p *Renderer) Name() string { return p.MarkupName } -// NeedPostProcess implements markup.Renderer func (p *Renderer) NeedPostProcess() bool { return p.MarkupRenderer.NeedPostProcess } -// Extensions returns the supported extensions of the tool -func (p *Renderer) Extensions() []string { - return p.FileExtensions +func (p *Renderer) FileNamePatterns() []string { + return p.FilePatterns } -// SanitizerRules implements markup.Renderer func (p *Renderer) SanitizerRules() []setting.MarkupSanitizerRule { return p.MarkupSanitizerRules } -// SanitizerDisabled disabled sanitize if return true -func (p *Renderer) SanitizerDisabled() bool { - return p.RenderContentMode == setting.RenderContentModeNoSanitizer || p.RenderContentMode == setting.RenderContentModeIframe -} - -// DisplayInIFrame represents whether render the content with an iframe -func (p *Renderer) DisplayInIFrame() bool { - return p.RenderContentMode == setting.RenderContentModeIframe +func (p *Renderer) GetExternalRendererOptions() (ret markup.ExternalRendererOptions) { + ret.SanitizerDisabled = p.RenderContentMode == setting.RenderContentModeNoSanitizer || p.RenderContentMode == setting.RenderContentModeIframe + ret.DisplayInIframe = p.RenderContentMode == setting.RenderContentModeIframe + ret.ContentSandbox = p.RenderContentSandbox + return ret } func envMark(envName string) string { @@ -81,7 +75,10 @@ func (p *Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io. envMark("GITEA_PREFIX_SRC"), baseLinkSrc, envMark("GITEA_PREFIX_RAW"), baseLinkRaw, ).Replace(p.Command) - commands := strings.Fields(command) + commands, err := shellquote.Split(command) + if err != nil || len(commands) == 0 { + return fmt.Errorf("%s invalid command %q: %w", p.Name(), p.Command, err) + } args := commands[1:] if p.IsInputFile { diff --git a/modules/markup/external/openapi.go b/modules/markup/external/openapi.go new file mode 100644 index 00000000000..ac5eae53ffe --- /dev/null +++ b/modules/markup/external/openapi.go @@ -0,0 +1,79 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package external + +import ( + "fmt" + "html" + "io" + + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" +) + +type openAPIRenderer struct{} + +var ( + _ markup.PostProcessRenderer = (*openAPIRenderer)(nil) + _ markup.ExternalRenderer = (*openAPIRenderer)(nil) +) + +func (p *openAPIRenderer) Name() string { + return "openapi" +} + +func (p *openAPIRenderer) NeedPostProcess() bool { + return false +} + +func (p *openAPIRenderer) FileNamePatterns() []string { + return []string{ + "openapi.yaml", + "openapi.yml", + "openapi.json", + "swagger.yaml", + "swagger.yml", + "swagger.json", + } +} + +func (p *openAPIRenderer) SanitizerRules() []setting.MarkupSanitizerRule { + return nil +} + +func (p *openAPIRenderer) GetExternalRendererOptions() (ret markup.ExternalRendererOptions) { + ret.SanitizerDisabled = true + ret.DisplayInIframe = true + ret.ContentSandbox = "" + return ret +} + +func (p *openAPIRenderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error { + content, err := util.ReadWithLimit(input, int(setting.UI.MaxDisplayFileSize)) + if err != nil { + return err + } + // TODO: can extract this to a tmpl file later + _, err = io.WriteString(output, fmt.Sprintf( + ` + + + + + + +
    + + +`, + setting.StaticURLPrefix, + setting.AssetVersion, + html.EscapeString(ctx.RenderOptions.RelativePath), + html.EscapeString(util.UnsafeBytesToString(content)), + setting.StaticURLPrefix, + setting.AssetVersion, + )) + return err +} diff --git a/modules/markup/html.go b/modules/markup/html.go index 51afd4be007..1c2ae6918de 100644 --- a/modules/markup/html.go +++ b/modules/markup/html.go @@ -12,7 +12,9 @@ import ( "strings" "sync" + "code.gitea.io/gitea/modules/htmlutil" "code.gitea.io/gitea/modules/markup/common" + "code.gitea.io/gitea/modules/translation" "golang.org/x/net/html" "golang.org/x/net/html/atom" @@ -60,7 +62,7 @@ var globalVars = sync.OnceValue(func() *globalVarsType { v.shortLinkPattern = regexp.MustCompile(`\[\[(.*?)\]\](\w*)`) // anyHashPattern splits url containing SHA into parts - v.anyHashPattern = regexp.MustCompile(`https?://(?:\S+/){4,5}([0-9a-f]{40,64})(/[-+~%./\w]+)?(\?[-+~%.\w&=]+)?(#[-+~%.\w]+)?`) + v.anyHashPattern = regexp.MustCompile(`https?://(?:\S+/){4,5}([0-9a-f]{40,64})((\.\w+)*)(/[-+~%./\w]+)?(\?[-+~%.\w&=]+)?(#[-+~%.\w]+)?`) // comparePattern matches "http://domain/org/repo/compare/COMMIT1...COMMIT2#hash" v.comparePattern = regexp.MustCompile(`https?://(?:\S+/){4,5}([0-9a-f]{7,64})(\.\.\.?)([0-9a-f]{7,64})?(#[-+~_%.a-zA-Z0-9]+)?`) @@ -234,6 +236,49 @@ func postProcessString(ctx *RenderContext, procs []processor, content string) (s return buf.String(), nil } +func RenderTocHeadingItems(ctx *RenderContext, nodeDetailsAttrs map[string]string, out io.Writer) { + locale, ok := ctx.Value(translation.ContextKey).(translation.Locale) + if !ok { + locale = translation.NewLocale("") + } + _, _ = htmlutil.HTMLPrintTag(out, "details", nodeDetailsAttrs) + _, _ = htmlutil.HTMLPrintf(out, "%s\n", locale.TrString("toc")) + + baseLevel := 6 + for _, header := range ctx.TocHeadingItems { + if header.HeadingLevel < baseLevel { + baseLevel = header.HeadingLevel + } + } + + currentLevel := baseLevel + indent := []byte{' ', ' '} + _, _ = htmlutil.HTMLPrint(out, "
    \n") + currentLevel-- + } + _, _ = htmlutil.HTMLPrint(out, "\n\n") +} + func postProcess(ctx *RenderContext, procs []processor, input io.Reader, output io.Writer) error { if !ctx.usedByRender && ctx.RenderHelper != nil { defer ctx.RenderHelper.CleanUp() @@ -284,6 +329,9 @@ func postProcess(ctx *RenderContext, procs []processor, input io.Reader, output } // Render everything to buf. + if ctx.TocShowInSection == TocShowInMain && len(ctx.TocHeadingItems) > 0 { + RenderTocHeadingItems(ctx, nil, output) + } for _, node := range newNodes { if err := html.Render(output, node); err != nil { return fmt.Errorf("markup.postProcess: html.Render: %w", err) @@ -314,7 +362,7 @@ func visitNode(ctx *RenderContext, procs []processor, node *html.Node) *html.Nod return node.NextSibling } - processNodeAttrID(node) + processNodeHeadingAndID(ctx, node) processFootnoteNode(ctx, node) // FIXME: the footnote processing should be done in the "footnote.go" renderer directly if isEmojiNode(node) { diff --git a/modules/markup/html_commit.go b/modules/markup/html_commit.go index fe7a034967a..a2c5160674f 100644 --- a/modules/markup/html_commit.go +++ b/modules/markup/html_commit.go @@ -4,24 +4,27 @@ package markup import ( + "fmt" "slices" "strings" "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/references" - "code.gitea.io/gitea/modules/util" "golang.org/x/net/html" "golang.org/x/net/html/atom" ) type anyHashPatternResult struct { - PosStart int - PosEnd int - FullURL string - CommitID string - SubPath string - QueryHash string + PosStart int + PosEnd int + FullURL string + CommitID string + CommitExt string + SubPath string + QueryParams string + QueryHash string } func createCodeLink(href, content, class string) *html.Node { @@ -56,7 +59,11 @@ func anyHashPatternExtract(s string) (ret anyHashPatternResult, ok bool) { return ret, false } - ret.PosStart, ret.PosEnd = m[0], m[1] + pos := 0 + + ret.PosStart, ret.PosEnd = m[pos], m[pos+1] + pos += 2 + ret.FullURL = s[ret.PosStart:ret.PosEnd] if strings.HasSuffix(ret.FullURL, ".") { // if url ends in '.', it's very likely that it is not part of the actual url but used to finish a sentence. @@ -67,14 +74,24 @@ func anyHashPatternExtract(s string) (ret anyHashPatternResult, ok bool) { } } - ret.CommitID = s[m[2]:m[3]] - if m[5] > 0 { - ret.SubPath = s[m[4]:m[5]] - } + ret.CommitID = s[m[pos]:m[pos+1]] + pos += 2 - lastStart, lastEnd := m[len(m)-2], m[len(m)-1] - if lastEnd > 0 { - ret.QueryHash = s[lastStart:lastEnd][1:] + ret.CommitExt = s[m[pos]:m[pos+1]] + pos += 4 + + if m[pos] > 0 { + ret.SubPath = s[m[pos]:m[pos+1]] + } + pos += 2 + + if m[pos] > 0 { + ret.QueryParams = s[m[pos]:m[pos+1]] + } + pos += 2 + + if m[pos] > 0 { + ret.QueryHash = s[m[pos]:m[pos+1]][1:] } return ret, true } @@ -96,12 +113,20 @@ func fullHashPatternProcessor(ctx *RenderContext, node *html.Node) { continue } text := base.ShortSha(ret.CommitID) + if ret.CommitExt != "" { + text += ret.CommitExt + } if ret.SubPath != "" { text += ret.SubPath } if ret.QueryHash != "" { text += " (" + ret.QueryHash + ")" } + // only turn commit links to the current instance into hash link + if !httplib.IsCurrentGiteaSiteURL(ctx, ret.FullURL) { + node = node.NextSibling + continue + } replaceContent(node, ret.PosStart, ret.PosEnd, createCodeLink(ret.FullURL, text, "commit")) node = node.NextSibling.NextSibling } @@ -148,6 +173,12 @@ func comparePatternProcessor(ctx *RenderContext, node *html.Node) { } } + // only turn compare links to the current instance into hash link + if !httplib.IsCurrentGiteaSiteURL(ctx, urlFull) { + node = node.NextSibling + continue + } + text := text1 + textDots + text2 if hash != "" { text += " (" + hash + ")" @@ -188,7 +219,7 @@ func hashCurrentPatternProcessor(ctx *RenderContext, node *html.Node) { continue } - link := "/:root/" + util.URLJoin(ctx.RenderOptions.Metas["user"], ctx.RenderOptions.Metas["repo"], "commit", hash) + link := fmt.Sprintf("/:root/%s/%s/commit/%s", ctx.RenderOptions.Metas["user"], ctx.RenderOptions.Metas["repo"], hash) replaceContent(node, m[2], m[3], createCodeLink(link, base.ShortSha(hash), "commit")) start = 0 node = node.NextSibling.NextSibling @@ -205,7 +236,7 @@ func commitCrossReferencePatternProcessor(ctx *RenderContext, node *html.Node) { } refText := ref.Owner + "/" + ref.Name + "@" + base.ShortSha(ref.CommitSha) - linkHref := "/:root/" + util.URLJoin(ref.Owner, ref.Name, "commit", ref.CommitSha) + linkHref := fmt.Sprintf("/:root/%s/%s/commit/%s", ref.Owner, ref.Name, ref.CommitSha) link := createLink(ctx, linkHref, refText, "commit") replaceContent(node, ref.RefLocation.Start, ref.RefLocation.End, link) diff --git a/modules/markup/html_emoji.go b/modules/markup/html_emoji.go index c6380654252..91ba26c6769 100644 --- a/modules/markup/html_emoji.go +++ b/modules/markup/html_emoji.go @@ -5,6 +5,7 @@ package markup import ( "strings" + "unicode" "code.gitea.io/gitea/modules/emoji" "code.gitea.io/gitea/modules/setting" @@ -66,26 +67,31 @@ func emojiShortCodeProcessor(ctx *RenderContext, node *html.Node) { } m[0] += start m[1] += start - start = m[1] alias := node.Data[m[0]:m[1]] - alias = strings.ReplaceAll(alias, ":", "") - converted := emoji.FromAlias(alias) - if converted == nil { - // check if this is a custom reaction - if _, exist := setting.UI.CustomEmojisMap[alias]; exist { - replaceContent(node, m[0], m[1], createCustomEmoji(ctx, alias)) - node = node.NextSibling.NextSibling - start = 0 - continue - } + + var nextChar byte + if m[1] < len(node.Data) { + nextChar = node.Data[m[1]] + } + if nextChar == ':' || unicode.IsLetter(rune(nextChar)) || unicode.IsDigit(rune(nextChar)) { continue } - replaceContent(node, m[0], m[1], createEmoji(ctx, converted.Emoji, converted.Description)) - node = node.NextSibling.NextSibling - start = 0 + alias = strings.Trim(alias, ":") + converted := emoji.FromAlias(alias) + if converted != nil { + // standard emoji + replaceContent(node, m[0], m[1], createEmoji(ctx, converted.Emoji, converted.Description)) + node = node.NextSibling.NextSibling + start = 0 // restart searching start since node has changed + } else if _, exist := setting.UI.CustomEmojisMap[alias]; exist { + // custom reaction + replaceContent(node, m[0], m[1], createCustomEmoji(ctx, alias)) + node = node.NextSibling.NextSibling + start = 0 // restart searching start since node has changed + } } } diff --git a/modules/markup/html_internal_test.go b/modules/markup/html_internal_test.go index 467cc509d0b..3a91797e25b 100644 --- a/modules/markup/html_internal_test.go +++ b/modules/markup/html_internal_test.go @@ -23,12 +23,12 @@ const ( // externalIssueLink an HTML link to an alphanumeric-style issue func externalIssueLink(baseURL, class, name string) string { - return link(util.URLJoin(baseURL, name), class, name) + return link(strings.TrimSuffix(baseURL, "/")+"/"+name, class, name) } // numericLink an HTML to a numeric-style issue func numericIssueLink(baseURL, class string, index int, marker string) string { - return link(util.URLJoin(baseURL, strconv.Itoa(index)), class, fmt.Sprintf("%s%d", marker, index)) + return link(strings.TrimSuffix(baseURL, "/")+"/"+strconv.Itoa(index), class, fmt.Sprintf("%s%d", marker, index)) } // link an HTML link @@ -116,7 +116,7 @@ func TestRender_IssueIndexPattern2(t *testing.T) { links := make([]any, len(indices)) for i, index := range indices { - links[i] = numericIssueLink(util.URLJoin("/test-owner/test-repo", path), "ref-issue", index, marker) + links[i] = numericIssueLink("/test-owner/test-repo/"+path, "ref-issue", index, marker) } expectedNil := fmt.Sprintf(expectedFmt, links...) testRenderIssueIndexPattern(t, s, expectedNil, NewTestRenderContext(TestAppURL, localMetas)) @@ -210,7 +210,7 @@ func TestRender_IssueIndexPattern5(t *testing.T) { metas["regexp"] = pattern links := make([]any, len(ids)) for i, id := range ids { - links[i] = link(util.URLJoin("https://someurl.com/someUser/someRepo/", id), "ref-issue ref-external-issue", names[i]) + links[i] = link("https://someurl.com/someUser/someRepo/"+id, "ref-issue ref-external-issue", names[i]) } expected := fmt.Sprintf(expectedFmt, links...) @@ -288,18 +288,18 @@ func TestRender_AutoLink(t *testing.T) { } // render valid issue URLs - test(util.URLJoin(TestRepoURL, "issues", "3333"), - numericIssueLink(util.URLJoin(TestRepoURL, "issues"), "ref-issue", 3333, "#")) + test(TestRepoURL+"issues/3333", + numericIssueLink(TestRepoURL+"issues", "ref-issue", 3333, "#")) // render valid commit URLs - tmp := util.URLJoin(TestRepoURL, "commit", "d8a994ef243349f321568f9e36d5c3f444b99cae") + tmp := TestRepoURL + "commit/d8a994ef243349f321568f9e36d5c3f444b99cae" test(tmp, "d8a994ef24") tmp += "#diff-2" test(tmp, "d8a994ef24 (diff-2)") // render other commit URLs tmp = "https://external-link.gitea.io/go-gitea/gitea/commit/d8a994ef243349f321568f9e36d5c3f444b99cae#diff-2" - test(tmp, "d8a994ef24 (diff-2)") + test(tmp, ""+tmp+"") } func TestRender_FullIssueURLs(t *testing.T) { diff --git a/modules/markup/html_issue.go b/modules/markup/html_issue.go index 85bec5db20c..a94abb38831 100644 --- a/modules/markup/html_issue.go +++ b/modules/markup/html_issue.go @@ -4,6 +4,7 @@ package markup import ( + "fmt" "strconv" "strings" @@ -162,7 +163,7 @@ func issueIndexPatternProcessor(ctx *RenderContext, node *html.Node) { issueOwner := util.Iif(ref.Owner == "", ctx.RenderOptions.Metas["user"], ref.Owner) issueRepo := util.Iif(ref.Owner == "", ctx.RenderOptions.Metas["repo"], ref.Name) issuePath := util.Iif(ref.IsPull, "pulls", "issues") - linkHref := "/:root/" + util.URLJoin(issueOwner, issueRepo, issuePath, ref.Issue) + linkHref := fmt.Sprintf("/:root/%s/%s/%s/%s", issueOwner, issueRepo, issuePath, ref.Issue) // at the moment, only render the issue index in a full line (or simple line) as icon+title // otherwise it would be too noisy for "take #1 as an example" in a sentence diff --git a/modules/markup/html_link.go b/modules/markup/html_link.go index 43faef16819..c84e0b90d81 100644 --- a/modules/markup/html_link.go +++ b/modules/markup/html_link.go @@ -33,7 +33,7 @@ func shortLinkProcessor(ctx *RenderContext, node *html.Node) { // Of text and link contents sl := strings.SplitSeq(content, "|") for v := range sl { - if equalPos := strings.IndexByte(v, '='); equalPos == -1 { + if found := strings.Contains(v, "="); !found { // There is no equal in this argument; this is a mandatory arg if props["name"] == "" { if IsFullURLString(v) { @@ -55,8 +55,8 @@ func shortLinkProcessor(ctx *RenderContext, node *html.Node) { } else { // There is an equal; optional argument. - sep := strings.IndexByte(v, '=') - key, val := v[:sep], html.UnescapeString(v[sep+1:]) + before, after, _ := strings.Cut(v, "=") + key, val := before, html.UnescapeString(after) // When parsing HTML, x/net/html will change all quotes which are // not used for syntax into UTF-8 quotes. So checking val[0] won't @@ -113,16 +113,17 @@ func shortLinkProcessor(ctx *RenderContext, node *html.Node) { } childNode.Parent = linkNode absoluteLink := IsFullURLString(link) - if !absoluteLink { + // FIXME: it should be fully refactored in the future, it uses various hacky approaches to guess how to encode a path for wiki + // When a link contains "/", then we assume that the user has provided a well-encoded link. + if !absoluteLink && !strings.Contains(link, "/") { + // So only guess for links without "/". if image { link = strings.ReplaceAll(link, " ", "+") } else { // the hacky wiki name encoding: space to "-" link = strings.ReplaceAll(link, " ", "-") // FIXME: it should support dashes in the link, eg: "the-dash-support.-" } - if !strings.Contains(link, "/") { - link = url.PathEscape(link) // FIXME: it doesn't seem right and it might cause double-escaping - } + link = url.PathEscape(link) } if image { title := props["title"] @@ -208,7 +209,6 @@ func createDescriptionLink(href, content string) *html.Node { Attr: []html.Attribute{ {Key: "href", Val: href}, {Key: "target", Val: "_blank"}, - {Key: "rel", Val: "noopener noreferrer"}, }, } textNode.Parent = linkNode diff --git a/modules/markup/html_mention.go b/modules/markup/html_mention.go index f97c034cf3b..00cd51ca949 100644 --- a/modules/markup/html_mention.go +++ b/modules/markup/html_mention.go @@ -4,6 +4,7 @@ package markup import ( + "fmt" "strings" "code.gitea.io/gitea/modules/references" @@ -26,14 +27,11 @@ func mentionProcessor(ctx *RenderContext, node *html.Node) { loc.End += start mention := node.Data[loc.Start:loc.End] teams, ok := ctx.RenderOptions.Metas["teams"] - // FIXME: util.URLJoin may not be necessary here: - // - setting.AppURL is defined to have a terminal '/' so unless mention[1:] - // is an AppSubURL link we can probably fallback to concatenation. - // team mention should follow @orgName/teamName style + if ok && strings.Contains(mention, "/") { mentionOrgAndTeam := strings.Split(mention, "/") if mentionOrgAndTeam[0][1:] == ctx.RenderOptions.Metas["org"] && strings.Contains(teams, ","+strings.ToLower(mentionOrgAndTeam[1])+",") { - link := "/:root/" + util.URLJoin("org", ctx.RenderOptions.Metas["org"], "teams", mentionOrgAndTeam[1]) + link := fmt.Sprintf("/:root/org/%s/teams/%s", ctx.RenderOptions.Metas["org"], mentionOrgAndTeam[1]) replaceContent(node, loc.Start, loc.End, createLink(ctx, link, mention, "" /*mention*/)) node = node.NextSibling.NextSibling start = 0 diff --git a/modules/markup/html_node.go b/modules/markup/html_node.go index 4eb78fdd2b0..f98e7429a28 100644 --- a/modules/markup/html_node.go +++ b/modules/markup/html_node.go @@ -6,13 +6,15 @@ package markup import ( "strings" + "code.gitea.io/gitea/modules/markup/common" + "golang.org/x/net/html" ) func isAnchorIDUserContent(s string) bool { // blackfridayExtRegex is for blackfriday extensions create IDs like fn:user-content-footnote // old logic: blackfridayExtRegex = regexp.MustCompile(`[^:]*:user-content-`) - return strings.HasPrefix(s, "user-content-") || strings.Contains(s, ":user-content-") + return strings.HasPrefix(s, "user-content-") || strings.Contains(s, ":user-content-") || isAnchorIDFootnote(s) } func isAnchorIDFootnote(s string) bool { @@ -23,16 +25,80 @@ func isAnchorHrefFootnote(s string) bool { return strings.HasPrefix(s, "#fnref:user-content-") || strings.HasPrefix(s, "#fn:user-content-") } -func processNodeAttrID(node *html.Node) { +// isHeadingTag returns true if the node is a heading tag (h1-h6) +func isHeadingTag(node *html.Node) bool { + return node.Type == html.ElementNode && + len(node.Data) == 2 && + node.Data[0] == 'h' && + node.Data[1] >= '1' && node.Data[1] <= '6' +} + +// getNodeText extracts the text content from a node and its children +func getNodeText(node *html.Node, cached **string) string { + if *cached != nil { + return **cached + } + var text strings.Builder + var extractText func(*html.Node) + extractText = func(n *html.Node) { + if n.Type == html.TextNode { + text.WriteString(n.Data) + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + extractText(c) + } + } + extractText(node) + textStr := text.String() + *cached = &textStr + return textStr +} + +func processNodeHeadingAndID(ctx *RenderContext, node *html.Node) { + // TODO: handle duplicate IDs, need to track existing IDs in the document // Add user-content- to IDs and "#" links if they don't already have them, // and convert the link href to a relative link to the host root + attrIDVal := "" for idx, attr := range node.Attr { if attr.Key == "id" { - if !isAnchorIDUserContent(attr.Val) { - node.Attr[idx].Val = "user-content-" + attr.Val + attrIDVal = attr.Val + if !isAnchorIDUserContent(attrIDVal) { + attrIDVal = "user-content-" + attrIDVal + node.Attr[idx].Val = attrIDVal } } } + + if !isHeadingTag(node) || !ctx.RenderOptions.EnableHeadingIDGeneration { + return + } + + // For heading tags (h1-h6) without an id attribute, generate one from the text content. + // This ensures HTML headings like

    Title

    get proper permalink anchors + // matching the behavior of Markdown headings. + // Only enabled for repository files and wiki pages via EnableHeadingIDGeneration option. + var nodeTextCached *string + if attrIDVal == "" { + nodeText := getNodeText(node, &nodeTextCached) + if nodeText != "" { + // Use the same CleanValue function used by Markdown heading ID generation + attrIDVal = string(common.CleanValue([]byte(nodeText))) + if attrIDVal != "" { + attrIDVal = "user-content-" + attrIDVal + node.Attr = append(node.Attr, html.Attribute{Key: "id", Val: attrIDVal}) + } + } + } + if ctx.TocShowInSection != "" { + nodeText := getNodeText(node, &nodeTextCached) + if nodeText != "" && attrIDVal != "" { + ctx.TocHeadingItems = append(ctx.TocHeadingItems, &TocHeadingItem{ + HeadingLevel: int(node.Data[1] - '0'), + AnchorID: attrIDVal, + InnerText: nodeText, + }) + } + } } func processFootnoteNode(ctx *RenderContext, node *html.Node) { diff --git a/modules/markup/html_node_test.go b/modules/markup/html_node_test.go new file mode 100644 index 00000000000..007e3c2a122 --- /dev/null +++ b/modules/markup/html_node_test.go @@ -0,0 +1,104 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package markup + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestProcessNodeAttrID_HTMLHeadingWithoutID(t *testing.T) { + // Test that HTML headings without id get an auto-generated id from their text content + // when EnableHeadingIDGeneration is true (for repo files and wiki pages) + testCases := []struct { + name string + input string + expected string + }{ + { + name: "h1 without id", + input: `

    Heading without ID

    `, + expected: `

    Heading without ID

    `, + }, + { + name: "h2 without id", + input: `

    Another Heading

    `, + expected: `

    Another Heading

    `, + }, + { + name: "h3 without id", + input: `

    Third Level

    `, + expected: `

    Third Level

    `, + }, + { + name: "h1 with existing id should keep it", + input: `

    Heading with ID

    `, + expected: `

    Heading with ID

    `, + }, + { + name: "h1 with user-content prefix should not double prefix", + input: `

    Already Prefixed

    `, + expected: `

    Already Prefixed

    `, + }, + { + name: "heading with special characters", + input: `

    What is Wine Staging?

    `, + expected: `

    What is Wine Staging?

    `, + }, + { + name: "heading with nested elements", + input: `

    Bold and Italic

    `, + expected: `

    Bold and Italic

    `, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var result strings.Builder + ctx := NewTestRenderContext().WithEnableHeadingIDGeneration(true) + err := PostProcessDefault(ctx, strings.NewReader(tc.input), &result) + assert.NoError(t, err) + assert.Equal(t, tc.expected, strings.TrimSpace(result.String())) + }) + } +} + +func TestProcessNodeAttrID_SkipHeadingIDForComments(t *testing.T) { + // Test that HTML headings in comment-like contexts (issue comments) + // do NOT get auto-generated IDs to avoid duplicate IDs on pages with multiple documents. + // This is controlled by EnableHeadingIDGeneration which defaults to false. + testCases := []struct { + name string + input string + expected string + }{ + { + name: "h1 without id in comment context", + input: `

    Heading without ID

    `, + expected: `

    Heading without ID

    `, + }, + { + name: "h2 without id in comment context", + input: `

    Another Heading

    `, + expected: `

    Another Heading

    `, + }, + { + name: "h1 with existing id should still be prefixed", + input: `

    Heading with ID

    `, + expected: `

    Heading with ID

    `, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var result strings.Builder + // Default context without EnableHeadingIDGeneration (simulates comment rendering) + err := PostProcessDefault(NewTestRenderContext(), strings.NewReader(tc.input), &result) + assert.NoError(t, err) + assert.Equal(t, tc.expected, strings.TrimSpace(result.String())) + }) + } +} diff --git a/modules/markup/html_test.go b/modules/markup/html_test.go index 5fdbf43f7cb..e62747c7241 100644 --- a/modules/markup/html_test.go +++ b/modules/markup/html_test.go @@ -34,15 +34,15 @@ func TestRender_Commits(t *testing.T) { sha := "65f1bf27bc3bf70f64657658635e66094edbcb4d" repo := markup.TestAppURL + testRepoOwnerName + "/" + testRepoName + "/" - commit := util.URLJoin(repo, "commit", sha) + commit := repo + "commit/" + sha commitPath := "/user13/repo11/commit/" + sha - tree := util.URLJoin(repo, "tree", sha, "src") + tree := repo + "tree/" + sha + "/src" - file := util.URLJoin(repo, "commit", sha, "example.txt") + file := repo + "commit/" + sha + "/example.txt" fileWithExtra := file + ":" fileWithHash := file + "#L2" fileWithHasExtra := file + "#L2:" - commitCompare := util.URLJoin(repo, "compare", sha+"..."+sha) + commitCompare := repo + "compare/" + sha + "..." + sha commitCompareWithHash := commitCompare + "#L2" test(sha, `

    65f1bf27bc

    `) @@ -71,6 +71,7 @@ func TestRender_Commits(t *testing.T) { } func TestRender_CrossReferences(t *testing.T) { + defer testModule.MockVariableValue(&setting.AppURL, markup.TestAppURL)() defer testModule.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)() test := func(input, expected string) { rctx := markup.NewTestRenderContext(markup.TestAppURL, localMetas).WithRelativePath("a.md") @@ -89,19 +90,29 @@ func TestRender_CrossReferences(t *testing.T) { "/home/gitea/go-gitea/gitea#12345", `

    /home/gitea/go-gitea/gitea#12345

    `) test( - util.URLJoin(markup.TestAppURL, "gogitea", "gitea", "issues", "12345"), - `

    gogitea/gitea#12345

    `) + markup.TestAppURL+"gogitea/gitea/issues/12345", + `

    gogitea/gitea#12345

    `) test( - util.URLJoin(markup.TestAppURL, "go-gitea", "gitea", "issues", "12345"), - `

    go-gitea/gitea#12345

    `) + markup.TestAppURL+"go-gitea/gitea/issues/12345", + `

    go-gitea/gitea#12345

    `) test( - util.URLJoin(markup.TestAppURL, "gogitea", "some-repo-name", "issues", "12345"), - `

    gogitea/some-repo-name#12345

    `) + markup.TestAppURL+"gogitea/some-repo-name/issues/12345", + `

    gogitea/some-repo-name#12345

    `) - inputURL := "https://host/a/b/commit/0123456789012345678901234567890123456789/foo.txt?a=b#L2-L3" + inputURL := setting.AppURL + "a/b/commit/0123456789012345678901234567890123456789/foo.txt?a=b#L2-L3" test( inputURL, `

    0123456789/foo.txt (L2-L3)

    `) + + inputURL = setting.AppURL + "repo/owner/archive/0123456789012345678901234567890123456789.tar.gz" + test( + inputURL, + `

    0123456789.tar.gz

    `) + + inputURL = setting.AppURL + "owner/repo/commit/0123456789012345678901234567890123456789.patch?key=val" + test( + inputURL, + `

    0123456789.patch

    `) } func TestRender_links(t *testing.T) { @@ -357,17 +368,14 @@ func TestRender_emoji(t *testing.T) { `

    😎🤪🔐🤑❓

    `) // should match nothing - test( - "2001:0db8:85a3:0000:0000:8a2e:0370:7334", - `

    2001:0db8:85a3:0000:0000:8a2e:0370:7334

    `) - test( - ":not exist:", - `

    :not exist:

    `) + test(":100:200", `

    :100:200

    `) + test("std::thread::something", `

    std::thread::something

    `) + test(":not exist:", `

    :not exist:

    `) } func TestRender_ShortLinks(t *testing.T) { setting.AppURL = markup.TestAppURL - tree := util.URLJoin(markup.TestRepoURL, "src", "master") + tree := markup.TestRepoURL + "src/master" test := func(input, expected string) { buffer, err := markdown.RenderString(markup.NewTestRenderContext(tree), input) @@ -375,15 +383,15 @@ func TestRender_ShortLinks(t *testing.T) { assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(buffer))) } - url := util.URLJoin(tree, "Link") - otherURL := util.URLJoin(tree, "Other-Link") - encodedURL := util.URLJoin(tree, "Link%3F") - imgurl := util.URLJoin(tree, "Link.jpg") - otherImgurl := util.URLJoin(tree, "Link+Other.jpg") - encodedImgurl := util.URLJoin(tree, "Link+%23.jpg") - notencodedImgurl := util.URLJoin(tree, "some", "path", "Link+#.jpg") - renderableFileURL := util.URLJoin(tree, "markdown_file.md") - unrenderableFileURL := util.URLJoin(tree, "file.zip") + url := tree + "/Link" + otherURL := tree + "/Other-Link" + encodedURL := tree + "/Link%3F" + imgurl := tree + "/Link.jpg" + otherImgurl := tree + "/Link+Other.jpg" + encodedImgurl := tree + "/Link+%23.jpg" + notencodedImgurl := tree + "/some/path/Link%20#.jpg" + renderableFileURL := tree + "/markdown_file.md" + unrenderableFileURL := tree + "/file.zip" favicon := "http://google.com/favicon.ico" test( @@ -458,6 +466,8 @@ func TestRender_ShortLinks(t *testing.T) { "[[Name|Link #.jpg|alt=\"AltName\"|title='Title']]", `

    AltName

    `, ) + // FIXME: it's unable to resolve: [[link?k=v]] + // FIXME: it is a wrong test case, it is not an image, but a link with anchor "#.jpg" test( "[[some/path/Link #.jpg]]", `

    some/path/Link #.jpg

    `, @@ -568,13 +578,15 @@ func TestFuzz(t *testing.T) { } func TestIssue18471(t *testing.T) { - data := `http://domain/org/repo/compare/783b039...da951ce` + defer testModule.MockVariableValue(&setting.AppURL, markup.TestAppURL)() + + data := markup.TestAppURL + `org/repo/compare/783b039...da951ce` var res strings.Builder err := markup.PostProcessDefault(markup.NewTestRenderContext(localMetas), strings.NewReader(data), &res) assert.NoError(t, err) - assert.Equal(t, `783b039...da951ce`, res.String()) + assert.Equal(t, `783b039...da951ce`, res.String()) } func TestIsFullURL(t *testing.T) { diff --git a/modules/markup/html_toc_test.go b/modules/markup/html_toc_test.go new file mode 100644 index 00000000000..e93cfc93461 --- /dev/null +++ b/modules/markup/html_toc_test.go @@ -0,0 +1,60 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package markup_test + +import ( + "regexp" + "testing" + + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/test" + + "github.com/stretchr/testify/assert" +) + +func TestToCWithHTML(t *testing.T) { + defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)() + + t1 := `tag link and Bold` + t2 := "code block ``" + t3 := "markdown **bold**" + input := `--- +include_toc: true +--- + +# ` + t1 + ` +## ` + t2 + ` +#### ` + t3 + ` +## last +` + + renderCtx := markup.NewTestRenderContext().WithEnableHeadingIDGeneration(true) + resultHTML, err := markdown.RenderString(renderCtx, input) + assert.NoError(t, err) + result := string(resultHTML) + re := regexp.MustCompile(`(?s)
    .*?
    `) + result = re.ReplaceAllString(result, "\n") + expected := `
    toc + +
    + +

    tag link and Bold

    +

    code block <a>

    +

    markdown bold

    +

    last

    +` + assert.Equal(t, expected, result) +} diff --git a/modules/markup/internal/finalprocessor.go b/modules/markup/internal/finalprocessor.go index 14d46a161f0..4442afa0c9e 100644 --- a/modules/markup/internal/finalprocessor.go +++ b/modules/markup/internal/finalprocessor.go @@ -5,11 +5,13 @@ package internal import ( "bytes" + "html/template" "io" ) type finalProcessor struct { renderInternal *RenderInternal + extraHeadHTML template.HTML output io.Writer buf bytes.Buffer @@ -25,6 +27,32 @@ func (p *finalProcessor) Close() error { // because "postProcess" already does so. In the future we could optimize the code to process data on the fly. buf := p.buf.Bytes() buf = bytes.ReplaceAll(buf, []byte(` data-attr-class="`+p.renderInternal.secureIDPrefix), []byte(` class="`)) - _, err := p.output.Write(buf) + + tmp := bytes.TrimSpace(buf) + isLikelyHTML := len(tmp) != 0 && tmp[0] == '<' && tmp[len(tmp)-1] == '>' && bytes.Index(tmp, []byte(` 0 + if !isLikelyHTML { + // not HTML, write back directly + _, err := p.output.Write(buf) + return err + } + + // add our extra head HTML into output + headBytes := []byte("") + posHead := bytes.Index(buf, headBytes) + var part1, part2 []byte + if posHead >= 0 { + part1, part2 = buf[:posHead+len(headBytes)], buf[posHead+len(headBytes):] + } else { + part1, part2 = nil, buf + } + if len(part1) > 0 { + if _, err := p.output.Write(part1); err != nil { + return err + } + } + if _, err := io.WriteString(p.output, string(p.extraHeadHTML)); err != nil { + return err + } + _, err := p.output.Write(part2) return err } diff --git a/modules/markup/internal/internal_test.go b/modules/markup/internal/internal_test.go index 590bcbb67f3..a216d75203f 100644 --- a/modules/markup/internal/internal_test.go +++ b/modules/markup/internal/internal_test.go @@ -12,7 +12,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestRenderInternal(t *testing.T) { +func TestRenderInternalAttrs(t *testing.T) { cases := []struct { input, protected, recovered string }{ @@ -30,7 +30,7 @@ func TestRenderInternal(t *testing.T) { for _, c := range cases { var r RenderInternal out := &bytes.Buffer{} - in := r.init("sec", out) + in := r.init("sec", out, "") protected := r.ProtectSafeAttrs(template.HTML(c.input)) assert.EqualValues(t, c.protected, protected) _, _ = io.WriteString(in, string(protected)) @@ -41,7 +41,7 @@ func TestRenderInternal(t *testing.T) { var r1, r2 RenderInternal protected := r1.ProtectSafeAttrs(`
    `) assert.EqualValues(t, `
    `, protected, "non-initialized RenderInternal should not protect any attributes") - _ = r1.init("sec", nil) + _ = r1.init("sec", nil, "") protected = r1.ProtectSafeAttrs(`
    `) assert.EqualValues(t, `
    `, protected) assert.Equal(t, "data-attr-class", r1.SafeAttr("class")) @@ -54,8 +54,37 @@ func TestRenderInternal(t *testing.T) { assert.Empty(t, recovered) out2 := &bytes.Buffer{} - in2 := r2.init("sec-other", out2) + in2 := r2.init("sec-other", out2, "") _, _ = io.WriteString(in2, string(protected)) _ = in2.Close() assert.Equal(t, `
    `, out2.String(), "different secureID should not recover the value") } + +func TestRenderInternalExtraHead(t *testing.T) { + t.Run("HeadExists", func(t *testing.T) { + out := &bytes.Buffer{} + var r RenderInternal + in := r.init("sec", out, ``) + _, _ = io.WriteString(in, `any`) + _ = in.Close() + assert.Equal(t, `any`, out.String()) + }) + + t.Run("HeadNotExists", func(t *testing.T) { + out := &bytes.Buffer{} + var r RenderInternal + in := r.init("sec", out, ``) + _, _ = io.WriteString(in, `
    `) + _ = in.Close() + assert.Equal(t, `
    `, out.String()) + }) + + t.Run("NotHTML", func(t *testing.T) { + out := &bytes.Buffer{} + var r RenderInternal + in := r.init("sec", out, ``) + _, _ = io.WriteString(in, ``) + _ = in.Close() + assert.Equal(t, ``, out.String()) + }) +} diff --git a/modules/markup/internal/renderinternal.go b/modules/markup/internal/renderinternal.go index 7a3e37b120f..9fd9a1c0e83 100644 --- a/modules/markup/internal/renderinternal.go +++ b/modules/markup/internal/renderinternal.go @@ -29,19 +29,19 @@ type RenderInternal struct { secureIDPrefix string } -func (r *RenderInternal) Init(output io.Writer) io.WriteCloser { +func (r *RenderInternal) Init(output io.Writer, extraHeadHTML template.HTML) io.WriteCloser { buf := make([]byte, 12) _, err := rand.Read(buf) if err != nil { panic("unable to generate secure id") } - return r.init(base64.URLEncoding.EncodeToString(buf), output) + return r.init(base64.URLEncoding.EncodeToString(buf), output, extraHeadHTML) } -func (r *RenderInternal) init(secID string, output io.Writer) io.WriteCloser { +func (r *RenderInternal) init(secID string, output io.Writer, extraHeadHTML template.HTML) io.WriteCloser { r.secureID = secID r.secureIDPrefix = r.secureID + ":" - return &finalProcessor{renderInternal: r, output: output} + return &finalProcessor{renderInternal: r, output: output, extraHeadHTML: extraHeadHTML} } func (r *RenderInternal) RecoverProtectedValue(v string) (string, bool) { diff --git a/modules/markup/main_test.go b/modules/markup/main_test.go index 564f55ac119..a8dcff475d3 100644 --- a/modules/markup/main_test.go +++ b/modules/markup/main_test.go @@ -14,5 +14,7 @@ import ( func TestMain(m *testing.M) { setting.IsInTesting = true markup.RenderBehaviorForTesting.DisableAdditionalAttributes = true + setting.Markdown.FileNamePatterns = []string{"*.md"} + markup.RefreshFileNamePatterns() os.Exit(m.Run()) } diff --git a/modules/markup/markdown/goldmark.go b/modules/markup/markdown/goldmark.go index b28fa9824ef..555a171685b 100644 --- a/modules/markup/markdown/goldmark.go +++ b/modules/markup/markdown/goldmark.go @@ -41,11 +41,10 @@ func (g *ASTTransformer) applyElementDir(n ast.Node) { // Transform transforms the given AST tree. func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) { firstChild := node.FirstChild() - tocMode := "" ctx := pc.Get(renderContextKey).(*markup.RenderContext) rc := pc.Get(renderConfigKey).(*RenderConfig) - tocList := make([]Header, 0, 20) + tocMode := "" if rc.yamlNode != nil { metaNode := rc.toMetaNode(g) if metaNode != nil { @@ -60,8 +59,6 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa } switch v := n.(type) { - case *ast.Heading: - g.transformHeading(ctx, v, reader, &tocList) case *ast.Paragraph: g.applyElementDir(v) case *ast.List: @@ -79,19 +76,18 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa return ast.WalkContinue, nil }) - showTocInMain := tocMode == "true" /* old behavior, in main view */ || tocMode == "main" - showTocInSidebar := !showTocInMain && tocMode != "false" // not hidden, not main, then show it in sidebar - if len(tocList) > 0 && (showTocInMain || showTocInSidebar) { - if showTocInMain { - tocNode := createTOCNode(tocList, rc.Lang, nil) - node.InsertBefore(node, firstChild, tocNode) - } else { - tocNode := createTOCNode(tocList, rc.Lang, map[string]string{"open": "open"}) - ctx.SidebarTocNode = tocNode + if ctx.RenderOptions.EnableHeadingIDGeneration { + showTocInMain := tocMode == "true" /* old behavior, in main view */ || tocMode == "main" + showTocInSidebar := !showTocInMain && tocMode != "false" // not hidden, not main, then show it in sidebar + switch { + case showTocInMain: + ctx.TocShowInSection = markup.TocShowInMain + case showTocInSidebar: + ctx.TocShowInSection = markup.TocShowInSidebar } } - if len(rc.Lang) > 0 { + if rc.Lang != "" { node.SetAttributeString("lang", []byte(rc.Lang)) } } diff --git a/modules/markup/markdown/markdown.go b/modules/markup/markdown/markdown.go index 3b788432bac..cca44a87744 100644 --- a/modules/markup/markdown/markdown.go +++ b/modules/markup/markdown/markdown.go @@ -5,6 +5,7 @@ package markdown import ( + "bytes" "errors" "html/template" "io" @@ -21,10 +22,12 @@ import ( "github.com/yuin/goldmark" highlighting "github.com/yuin/goldmark-highlighting/v2" meta "github.com/yuin/goldmark-meta" + "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/renderer" "github.com/yuin/goldmark/renderer/html" + "github.com/yuin/goldmark/text" "github.com/yuin/goldmark/util" ) @@ -57,7 +60,7 @@ func (l *limitWriter) Write(data []byte) (int, error) { // newParserContext creates a parser.Context with the render context set func newParserContext(ctx *markup.RenderContext) parser.Context { - pc := parser.NewContext(parser.WithIDs(newPrefixedIDs())) + pc := parser.NewContext() pc.Set(renderContextKey, ctx) return pc } @@ -101,12 +104,48 @@ func (r *GlodmarkRender) highlightingRenderer(w util.BufWriter, c highlighting.C } } +type goldmarkEmphasisParser struct { + parser.InlineParser +} + +func goldmarkNewEmphasisParser() parser.InlineParser { + return &goldmarkEmphasisParser{parser.NewEmphasisParser()} +} + +func (s *goldmarkEmphasisParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { + line, _ := block.PeekLine() + if len(line) > 1 && line[0] == '_' { + // a special trick to avoid parsing emphasis in filenames like "module/__init__.py" + end := bytes.IndexByte(line[1:], '_') + mark := bytes.Index(line, []byte("_.py")) + // check whether the "end" matches "_.py" or "__.py" + if mark != -1 && (end == mark || end == mark-1) { + return nil + } + } + return s.InlineParser.Parse(parent, block, pc) +} + +func goldmarkDefaultParser() parser.Parser { + return parser.NewParser(parser.WithBlockParsers(parser.DefaultBlockParsers()...), + parser.WithInlineParsers([]util.PrioritizedValue{ + util.Prioritized(parser.NewCodeSpanParser(), 100), + util.Prioritized(parser.NewLinkParser(), 200), + util.Prioritized(parser.NewAutoLinkParser(), 300), + util.Prioritized(parser.NewRawHTMLParser(), 400), + util.Prioritized(goldmarkNewEmphasisParser(), 500), + }...), + parser.WithParagraphTransformers(parser.DefaultParagraphTransformers()...), + ) +} + // SpecializedMarkdown sets up the Gitea specific markdown extensions func SpecializedMarkdown(ctx *markup.RenderContext) *GlodmarkRender { // TODO: it could use a pool to cache the renderers to reuse them with different contexts // at the moment it is fast enough (see the benchmarks) r := &GlodmarkRender{ctx: ctx} r.goldmarkMarkdown = goldmark.New( + goldmark.WithParser(goldmarkDefaultParser()), goldmark.WithExtensions( extension.NewTable(extension.WithTableCellAlignMethod(extension.TableCellAlignAttribute)), extension.Strikethrough, @@ -131,7 +170,6 @@ func SpecializedMarkdown(ctx *markup.RenderContext) *GlodmarkRender { ), goldmark.WithParserOptions( parser.WithAttribute(), - parser.WithAutoHeadingID(), parser.WithASTTransformers(util.Prioritized(NewASTTransformer(&ctx.RenderInternal), 10000)), ), goldmark.WithRendererOptions(html.WithUnsafe()), @@ -202,30 +240,24 @@ func init() { markup.RegisterRenderer(Renderer{}) } -// Renderer implements markup.Renderer type Renderer struct{} var _ markup.PostProcessRenderer = (*Renderer)(nil) -// Name implements markup.Renderer func (Renderer) Name() string { return MarkupName } -// NeedPostProcess implements markup.PostProcessRenderer func (Renderer) NeedPostProcess() bool { return true } -// Extensions implements markup.Renderer -func (Renderer) Extensions() []string { - return setting.Markdown.FileExtensions +func (Renderer) FileNamePatterns() []string { + return setting.Markdown.FileNamePatterns } -// SanitizerRules implements markup.Renderer func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule { return []setting.MarkupSanitizerRule{} } -// Render implements markup.Renderer func (Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error { return render(ctx, input, output) } diff --git a/modules/markup/markdown/markdown_math_test.go b/modules/markup/markdown/markdown_math_test.go index a75f18d36a8..9e368cb689a 100644 --- a/modules/markup/markdown/markdown_math_test.go +++ b/modules/markup/markdown/markdown_math_test.go @@ -30,6 +30,10 @@ func TestMathRender(t *testing.T) { "$ a $", `

    a

    ` + nl, }, + { + "$a$$b$", + `

    ab

    ` + nl, + }, { "$a$ $b$", `

    a b

    ` + nl, @@ -59,7 +63,7 @@ func TestMathRender(t *testing.T) { `

    a$b $a a$b b$

    ` + nl, }, { - "a$x$", + "a$x$", // Pattern: "word$other$" The real world example is: "Price is between US$1 and US$2.", so don't parse this. `

    a$x$

    ` + nl, }, { @@ -70,6 +74,10 @@ func TestMathRender(t *testing.T) { "$a$ ($b$) [$c$] {$d$}", `

    a (b) [$c$] {$d$}

    ` + nl, }, + { + "[$a$](link)", + `

    a

    ` + nl, + }, { "$$a$$", `

    a

    ` + nl, diff --git a/modules/markup/markdown/markdown_test.go b/modules/markup/markdown/markdown_test.go index 4eb01bcc2da..261c4e780c9 100644 --- a/modules/markup/markdown/markdown_test.go +++ b/modules/markup/markdown/markdown_test.go @@ -14,7 +14,6 @@ import ( "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/test" - "code.gitea.io/gitea/modules/util" "github.com/stretchr/testify/assert" ) @@ -23,7 +22,6 @@ const ( AppURL = "http://localhost:3000/" testRepoOwnerName = "user13" testRepoName = "repo11" - FullURL = AppURL + testRepoOwnerName + "/" + testRepoName + "/" ) // these values should match the const above @@ -47,8 +45,9 @@ func TestRender_StandardLinks(t *testing.T) { func TestRender_Images(t *testing.T) { setting.AppURL = AppURL + const baseLink = "http://localhost:3000/user13/repo11" render := func(input, expected string) { - buffer, err := markdown.RenderString(markup.NewTestRenderContext(FullURL), input) + buffer, err := markdown.RenderString(markup.NewTestRenderContext(baseLink), input) assert.NoError(t, err) assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(buffer))) } @@ -56,7 +55,7 @@ func TestRender_Images(t *testing.T) { url := "../../.images/src/02/train.jpg" title := "Train" href := "https://gitea.io" - result := util.URLJoin(FullURL, url) + result := baseLink + "/.images/src/02/train.jpg" // resolved link should not go out of the base link // hint: With Markdown v2.5.2, there is a new syntax: [link](URL){:target="_blank"} , but we do not support it now render( @@ -88,6 +87,8 @@ func TestRender_Images(t *testing.T) { } func TestTotal_RenderString(t *testing.T) { + const FullURL = AppURL + testRepoOwnerName + "/" + testRepoName + "/" + setting.AppURL = AppURL defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)() // Test cases without ambiguous links (It is not right to copy a whole file here, instead it should clearly test what is being tested) @@ -258,7 +259,7 @@ This PR has been generated by [Renovate Bot](https://github.com/renovatebot/reno }, }) for i := range sameCases { - line, err := markdown.RenderString(markup.NewTestRenderContext(localMetas), sameCases[i]) + line, err := markdown.RenderString(markup.NewTestRenderContext(localMetas).WithEnableHeadingIDGeneration(true), sameCases[i]) assert.NoError(t, err) assert.Equal(t, testAnswers[i], string(line)) } @@ -482,6 +483,9 @@ foo: bar } func TestRenderLinks(t *testing.T) { + defer test.MockVariableValue(&setting.AppURL, AppURL)() + defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)() + input := ` space @mention-user${SPACE}${SPACE} /just/a/path.bin https://example.com/file.bin @@ -519,9 +523,9 @@ mail@domain.com remote image local image remote link -88fc37a3c0...12fc37a3c0 (hash) +https://example.com/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb pare -88fc37a3c0 +https://example.com/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit 👍 mail@domain.com @@ -529,10 +533,21 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit #123 space

    ` - defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)() result, err := markdown.RenderString(markup.NewTestRenderContext(localMetas), input) assert.NoError(t, err) assert.Equal(t, expected, string(result)) + + t.Run("LocalCommitAndCompare", func(t *testing.T) { + input := `http://localhost:3000/user/repo/commit/88fc37a3c0a4dda553bdcfc80c178a58247f42fb +http://localhost:3000/user/repo/compare/88fc37a3c0a4dda553bdcfc80c178a58247f42fb...12fc37a3c0a4dda553bdcfc80c178a58247f42fb#hash` + + expected := `

    88fc37a3c0 +88fc37a3c0...12fc37a3c0 (hash)

    +` + result, err := markdown.RenderString(markup.NewTestRenderContext(localMetas), input) + assert.NoError(t, err) + assert.Equal(t, expected, string(result)) + }) } func TestMarkdownLink(t *testing.T) { @@ -545,5 +560,11 @@ func TestMarkdownLink(t *testing.T) { assert.Equal(t, `

    link1 link2 link3

    +`, string(result)) + + input = "https://example.com/__init__.py" + result, err = markdown.RenderString(markup.NewTestRenderContext("/base", localMetas), input) + assert.NoError(t, err) + assert.Equal(t, `

    https://example.com/__init__.py

    `, string(result)) } diff --git a/modules/markup/markdown/math/inline_parser.go b/modules/markup/markdown/math/inline_parser.go index a711d1e1cd1..564861df90b 100644 --- a/modules/markup/markdown/math/inline_parser.go +++ b/modules/markup/markdown/math/inline_parser.go @@ -54,6 +54,10 @@ func isAlphanumeric(b byte) bool { return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') } +func isInMarkdownLinkText(block text.Reader, lineAfter []byte) bool { + return block.PrecendingCharacter() == '[' && bytes.HasPrefix(lineAfter, []byte("](")) +} + // Parse parses the current line and returns a result of parsing. func (parser *inlineParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { line, _ := block.PeekLine() @@ -115,7 +119,9 @@ func (parser *inlineParser) Parse(parent ast.Node, block text.Reader, pc parser. } // check valid ending character isValidEndingChar := isPunctuation(succeedingCharacter) || isParenthesesClose(succeedingCharacter) || - succeedingCharacter == ' ' || succeedingCharacter == '\n' || succeedingCharacter == 0 + succeedingCharacter == ' ' || succeedingCharacter == '\n' || succeedingCharacter == 0 || + succeedingCharacter == '$' || + isInMarkdownLinkText(block, line[i+len(stopMark):]) if checkSurrounding && !isValidEndingChar { break } diff --git a/modules/markup/markdown/prefixed_id.go b/modules/markup/markdown/prefixed_id.go deleted file mode 100644 index 63d7fadc0aa..00000000000 --- a/modules/markup/markdown/prefixed_id.go +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package markdown - -import ( - "bytes" - "fmt" - - "code.gitea.io/gitea/modules/container" - "code.gitea.io/gitea/modules/markup/common" - "code.gitea.io/gitea/modules/util" - - "github.com/yuin/goldmark/ast" -) - -type prefixedIDs struct { - values container.Set[string] -} - -// Generate generates a new element id. -func (p *prefixedIDs) Generate(value []byte, kind ast.NodeKind) []byte { - dft := []byte("id") - if kind == ast.KindHeading { - dft = []byte("heading") - } - return p.GenerateWithDefault(value, dft) -} - -// GenerateWithDefault generates a new element id. -func (p *prefixedIDs) GenerateWithDefault(value, dft []byte) []byte { - result := common.CleanValue(value) - if len(result) == 0 { - result = dft - } - if !bytes.HasPrefix(result, []byte("user-content-")) { - result = append([]byte("user-content-"), result...) - } - if p.values.Add(util.UnsafeBytesToString(result)) { - return result - } - for i := 1; ; i++ { - newResult := fmt.Sprintf("%s-%d", result, i) - if p.values.Add(newResult) { - return []byte(newResult) - } - } -} - -// Put puts a given element id to the used ids table. -func (p *prefixedIDs) Put(value []byte) { - p.values.Add(util.UnsafeBytesToString(value)) -} - -func newPrefixedIDs() *prefixedIDs { - return &prefixedIDs{ - values: make(container.Set[string]), - } -} diff --git a/modules/markup/markdown/toc.go b/modules/markup/markdown/toc.go deleted file mode 100644 index a11b9d03900..00000000000 --- a/modules/markup/markdown/toc.go +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package markdown - -import ( - "net/url" - - "code.gitea.io/gitea/modules/translation" - - "github.com/yuin/goldmark/ast" -) - -// Header holds the data about a header. -type Header struct { - Level int - Text string - ID string -} - -func createTOCNode(toc []Header, lang string, detailsAttrs map[string]string) ast.Node { - details := NewDetails() - summary := NewSummary() - - for k, v := range detailsAttrs { - details.SetAttributeString(k, []byte(v)) - } - - summary.AppendChild(summary, ast.NewString([]byte(translation.NewLocale(lang).TrString("toc")))) - details.AppendChild(details, summary) - ul := ast.NewList('-') - details.AppendChild(details, ul) - currentLevel := 6 - for _, header := range toc { - if header.Level < currentLevel { - currentLevel = header.Level - } - } - for _, header := range toc { - for currentLevel > header.Level { - ul = ul.Parent().(*ast.List) - currentLevel-- - } - for currentLevel < header.Level { - newL := ast.NewList('-') - ul.AppendChild(ul, newL) - currentLevel++ - ul = newL - } - li := ast.NewListItem(currentLevel * 2) - a := ast.NewLink() - a.Destination = []byte("#" + url.QueryEscape(header.ID)) - a.AppendChild(a, ast.NewString([]byte(header.Text))) - li.AppendChild(li, a) - ul.AppendChild(ul, li) - } - - return details -} diff --git a/modules/markup/markdown/transform_heading.go b/modules/markup/markdown/transform_heading.go deleted file mode 100644 index a229a7b1a4d..00000000000 --- a/modules/markup/markdown/transform_heading.go +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package markdown - -import ( - "fmt" - - "code.gitea.io/gitea/modules/markup" - "code.gitea.io/gitea/modules/util" - - "github.com/yuin/goldmark/ast" - "github.com/yuin/goldmark/text" -) - -func (g *ASTTransformer) transformHeading(_ *markup.RenderContext, v *ast.Heading, reader text.Reader, tocList *[]Header) { - for _, attr := range v.Attributes() { - if _, ok := attr.Value.([]byte); !ok { - v.SetAttribute(attr.Name, fmt.Appendf(nil, "%v", attr.Value)) - } - } - txt := v.Text(reader.Source()) //nolint:staticcheck // Text is deprecated - header := Header{ - Text: util.UnsafeBytesToString(txt), - Level: v.Level, - } - if id, found := v.AttributeString("id"); found { - header.ID = util.UnsafeBytesToString(id.([]byte)) - } - *tocList = append(*tocList, header) - g.applyElementDir(v) -} diff --git a/modules/markup/mdstripper/mdstripper.go b/modules/markup/mdstripper/mdstripper.go index 5a6504416ab..bf69051e876 100644 --- a/modules/markup/mdstripper/mdstripper.go +++ b/modules/markup/mdstripper/mdstripper.go @@ -46,7 +46,7 @@ func (r *stripRenderer) Render(w io.Writer, source []byte, doc ast.Node) error { coalesce := prevSibIsText r.processString( w, - v.Text(source), //nolint:staticcheck // Text is deprecated + v.Value(source), coalesce) if v.SoftLineBreak() { r.doubleSpace(w) @@ -165,7 +165,6 @@ func StripMarkdownBytes(rawBytes []byte) ([]byte, []string) { ), goldmark.WithParserOptions( parser.WithAttribute(), - parser.WithAutoHeadingID(), ), goldmark.WithRendererOptions( html.WithUnsafe(), diff --git a/modules/markup/orgmode/orgmode.go b/modules/markup/orgmode/orgmode.go index 93c335d2449..fd3071645ad 100644 --- a/modules/markup/orgmode/orgmode.go +++ b/modules/markup/orgmode/orgmode.go @@ -5,7 +5,6 @@ package orgmode import ( "fmt" - "html" "html/template" "io" "strings" @@ -17,7 +16,6 @@ import ( "code.gitea.io/gitea/modules/setting" "github.com/alecthomas/chroma/v2" - "github.com/alecthomas/chroma/v2/lexers" "github.com/niklasfasching/go-org/org" ) @@ -33,20 +31,16 @@ var ( _ markup.PostProcessRenderer = (*renderer)(nil) ) -// Name implements markup.Renderer func (renderer) Name() string { return "orgmode" } -// NeedPostProcess implements markup.PostProcessRenderer func (renderer) NeedPostProcess() bool { return true } -// Extensions implements markup.Renderer -func (renderer) Extensions() []string { - return []string{".org"} +func (renderer) FileNamePatterns() []string { + return []string{"*.org"} } -// SanitizerRules implements markup.Renderer func (renderer) SanitizerRules() []setting.MarkupSanitizerRule { return []setting.MarkupSanitizerRule{} } @@ -57,40 +51,20 @@ func Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error htmlWriter.HighlightCodeBlock = func(source, lang string, inline bool, params map[string]string) string { defer func() { if err := recover(); err != nil { + // catch the panic, log the error and return empty result log.Error("Panic in HighlightCodeBlock: %v\n%s", err, log.Stack(2)) - panic(err) } }() - w := &strings.Builder{} - lexer := lexers.Get(lang) - if lexer == nil && lang == "" { - lexer = lexers.Analyse(source) - if lexer == nil { - lexer = lexers.Fallback - } - lang = strings.ToLower(lexer.Config().Name) - } + lexer := highlight.DetectChromaLexerByFileName("", lang) // don't use content to detect, it is too slow + lexer = chroma.Coalesce(lexer) + sb := &strings.Builder{} // include language-x class as part of commonmark spec - if err := ctx.RenderInternal.FormatWithSafeAttrs(w, `
    `, lang); err != nil {
    -			return ""
    -		}
    -		if lexer == nil {
    -			if _, err := w.WriteString(html.EscapeString(source)); err != nil {
    -				return ""
    -			}
    -		} else {
    -			lexer = chroma.Coalesce(lexer)
    -			if _, err := w.WriteString(string(highlight.CodeFromLexer(lexer, source))); err != nil {
    -				return ""
    -			}
    -		}
    -		if _, err := w.WriteString("
    "); err != nil { - return "" - } - - return w.String() + _ = ctx.RenderInternal.FormatWithSafeAttrs(sb, `
    `, strings.ToLower(lexer.Config().Name))
    +		_, _ = sb.WriteString(string(highlight.RenderCodeByLexer(lexer, source)))
    +		_, _ = sb.WriteString("
    ") + return sb.String() } w := &orgWriter{rctx: ctx, HTMLWriter: htmlWriter} diff --git a/modules/markup/render.go b/modules/markup/render.go index 79f1f473c2c..5785dc5ad54 100644 --- a/modules/markup/render.go +++ b/modules/markup/render.go @@ -4,19 +4,22 @@ package markup import ( + "bytes" "context" "fmt" + "html/template" "io" "net/url" "strconv" "strings" "time" + "code.gitea.io/gitea/modules/htmlutil" "code.gitea.io/gitea/modules/markup/internal" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/typesniffer" "code.gitea.io/gitea/modules/util" - "github.com/yuin/goldmark/ast" "golang.org/x/sync/errgroup" ) @@ -52,6 +55,23 @@ type RenderOptions struct { // used by external render. the router "/org/repo/render/..." will output the rendered content in a standalone page InStandalonePage bool + + // EnableHeadingIDGeneration controls whether to auto-generate IDs for HTML headings without id attribute. + // This should be enabled for repository files and wiki pages, but disabled for comments to avoid duplicate IDs. + EnableHeadingIDGeneration bool +} + +type TocShowInSectionType string + +const ( + TocShowInSidebar TocShowInSectionType = "sidebar" + TocShowInMain TocShowInSectionType = "main" +) + +type TocHeadingItem struct { + HeadingLevel int + AnchorID string + InnerText string } // RenderContext represents a render context @@ -61,7 +81,8 @@ type RenderContext struct { // the context might be used by the "render" function, but it might also be used by "postProcess" function usedByRender bool - SidebarTocNode ast.Node + TocShowInSection TocShowInSectionType + TocHeadingItems []*TocHeadingItem RenderHelper RenderHelper RenderOptions RenderOptions @@ -110,6 +131,11 @@ func (ctx *RenderContext) WithInStandalonePage(v bool) *RenderContext { return ctx } +func (ctx *RenderContext) WithEnableHeadingIDGeneration(v bool) *RenderContext { + ctx.RenderOptions.EnableHeadingIDGeneration = v + return ctx +} + func (ctx *RenderContext) WithUseAbsoluteLink(v bool) *RenderContext { ctx.RenderOptions.UseAbsoluteLink = v return ctx @@ -120,31 +146,45 @@ func (ctx *RenderContext) WithHelper(helper RenderHelper) *RenderContext { return ctx } -// Render renders markup file to HTML with all specific handling stuff. -func Render(ctx *RenderContext, input io.Reader, output io.Writer) error { +func (ctx *RenderContext) DetectMarkupRenderer(prefetchBuf []byte) Renderer { if ctx.RenderOptions.MarkupType == "" && ctx.RenderOptions.RelativePath != "" { - ctx.RenderOptions.MarkupType = DetectMarkupTypeByFileName(ctx.RenderOptions.RelativePath) - if ctx.RenderOptions.MarkupType == "" { - return util.NewInvalidArgumentErrorf("unsupported file to render: %q", ctx.RenderOptions.RelativePath) + var sniffedType typesniffer.SniffedType + if len(prefetchBuf) > 0 { + sniffedType = typesniffer.DetectContentType(prefetchBuf) } + ctx.RenderOptions.MarkupType = DetectRendererTypeByPrefetch(ctx.RenderOptions.RelativePath, sniffedType, prefetchBuf) } + return renderers[ctx.RenderOptions.MarkupType] +} - renderer := renderers[ctx.RenderOptions.MarkupType] +func (ctx *RenderContext) DetectMarkupRendererByReader(in io.Reader) (Renderer, io.Reader, error) { + prefetchBuf := make([]byte, 512) + n, err := util.ReadAtMost(in, prefetchBuf) + if err != nil && err != io.EOF { + return nil, nil, err + } + prefetchBuf = prefetchBuf[:n] + renderer := ctx.DetectMarkupRenderer(prefetchBuf) if renderer == nil { - return util.NewInvalidArgumentErrorf("unsupported markup type: %q", ctx.RenderOptions.MarkupType) + return nil, nil, util.NewInvalidArgumentErrorf("unable to find a render") } + return renderer, io.MultiReader(bytes.NewReader(prefetchBuf), in), nil +} - if ctx.RenderOptions.RelativePath != "" { - if externalRender, ok := renderer.(ExternalRenderer); ok && externalRender.DisplayInIFrame() { - if !ctx.RenderOptions.InStandalonePage { - // for an external "DisplayInIFrame" render, it could only output its content in a standalone page - // otherwise, a `, - setting.AppSubURL, +func renderIFrame(ctx *RenderContext, sandbox string, output io.Writer) error { + src := fmt.Sprintf("%s/%s/%s/render/%s/%s", setting.AppSubURL, url.PathEscape(ctx.RenderOptions.Metas["user"]), url.PathEscape(ctx.RenderOptions.Metas["repo"]), - ctx.RenderOptions.Metas["RefTypeNameSubURL"], - url.PathEscape(ctx.RenderOptions.RelativePath), - )) + util.PathEscapeSegments(ctx.RenderOptions.Metas["RefTypeNameSubURL"]), + util.PathEscapeSegments(ctx.RenderOptions.RelativePath), + ) + + var sandboxAttrValue template.HTML + if sandbox != "" { + sandboxAttrValue = htmlutil.HTMLFormat(`sandbox="%s"`, sandbox) + } + iframe := htmlutil.HTMLFormat(``, src, sandboxAttrValue) + _, err := io.WriteString(output, string(iframe)) return err } @@ -185,13 +221,34 @@ func pipes() (io.ReadCloser, io.WriteCloser, func()) { } } -func render(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Writer) error { +func getExternalRendererOptions(renderer Renderer) (ret ExternalRendererOptions, _ bool) { + if externalRender, ok := renderer.(ExternalRenderer); ok { + return externalRender.GetExternalRendererOptions(), true + } + return ret, false +} + +func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Writer) error { + var extraHeadHTML template.HTML + if extOpts, ok := getExternalRendererOptions(renderer); ok && extOpts.DisplayInIframe { + if !ctx.RenderOptions.InStandalonePage { + // for an external "DisplayInIFrame" render, it could only output its content in a standalone page + // otherwise, a