0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-06-26 23:53:35 +02:00

Merge branch 'main' into puni9869/ISSUE-36299

This commit is contained in:
puni9869 2026-06-16 09:15:06 +05:30 committed by GitHub
commit 689d4b2c7c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 447 additions and 101 deletions

View File

@ -21,7 +21,7 @@ jobs:
timeout-minutes: 30
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: renovatebot/github-action@693b9ef15eec82123529a37c782242f091365961 # v46.1.14
- uses: renovatebot/github-action@8217b3fc286df088d7c27f3255fe8414463bc0fd # v46.1.15
with:
renovate-version: ${{ env.RENOVATE_VERSION }}
configurationFile: renovate.json5

View File

@ -131,7 +131,7 @@ jobs:
ports:
- "7700:7700"
redis:
image: redis:latest@sha256:e74c9b933d78e2829583d88f92793f4524752a15ac59c8baff2dd5ed000b7432
image: redis:latest@sha256:a505f8b9d8ac3ff7b0848055b4abf1901d6d77606774aa1e38bd37f1197ed2b5
options: >- # wait until redis has started
--health-cmd "redis-cli ping"
--health-interval 5s

View File

@ -16,7 +16,7 @@ EDITORCONFIG_CHECKER_PACKAGE ?= github.com/editorconfig-checker/editorconfig-che
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 # renovate: datasource=go
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.15 # renovate: datasource=go
MISSPELL_PACKAGE ?= github.com/golangci/misspell/cmd/misspell@v0.8.0 # renovate: datasource=go
SWAGGER_PACKAGE ?= github.com/go-swagger/go-swagger/cmd/swagger@v0.34.0 # renovate: datasource=go
SWAGGER_PACKAGE ?= github.com/go-swagger/go-swagger/cmd/swagger@v0.34.1 # renovate: datasource=go
XGO_PACKAGE ?= src.techknowlogick.com/xgo@v1.9.0 # renovate: datasource=go
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.3.0 # renovate: datasource=go
ACTIONLINT_PACKAGE ?= github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 # renovate: datasource=go

2
go.mod
View File

@ -105,6 +105,7 @@ require (
go.yaml.in/yaml/v4 v4.0.0-rc.5
golang.org/x/crypto v0.53.0
golang.org/x/image v0.42.0
golang.org/x/mod v0.37.0
golang.org/x/net v0.56.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.21.0
@ -267,7 +268,6 @@ require (
go.uber.org/zap/exp v0.3.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.45.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad // indirect

View File

@ -70,9 +70,11 @@ func (c *CommitMessage) MessageTrailer() CommitMessageTrailerValues {
var commitMessageTrailerSplit = sync.OnceValue(func() *regexp.Regexp {
// the sep is either something like "\n---\n" or "\n\n" in the body, or at the start of the body like "---\n"
return regexp.MustCompile(`(?s)^(?P<content>.*?)(?P<sep>^|^\n|^-{3,}\n|\n-{3,}\n|\n\n)(?P<trailer>(?:[A-Za-z0-9][-A-Za-z0-9]*:[^\n]*\n?)*)$`)
return regexp.MustCompile(`(?s)^(?P<content>.*?)(?P<sep>^|^\n|^-{3,}\n+|\n-{3,}\n+|\n\n)(?P<trailer>(?:[A-Za-z0-9][-A-Za-z0-9]*:[^\n]*\n?)*\n*)$`)
})
// CommitMessageSplitTrailer tries to split the message by the trailer separator
// content + sep + trailer will reconstruct the original message
func CommitMessageSplitTrailer(s string) (content, sep, trailer string) {
s = util.NormalizeStringEOL(s)
re := commitMessageTrailerSplit()

View File

@ -26,8 +26,10 @@ func TestCommitMessageTrailer(t *testing.T) {
{"a", "a", "", ""},
{"a\n\nk", "a\n\nk", "", ""},
{"a\n\nk:v", "a", "\n\n", "k:v"},
{"a\n\nk:v\n\n", "a", "\n\n", "k:v\n\n"},
{"a\n--\nk:v", "a\n--\nk:v", "", ""},
{"a\n---\nk:v", "a", "\n---\n", "k:v"},
{"a\n\n---\n\nk:v", "a\n", "\n---\n\n", "k:v"},
{"k: v", "", "", "k: v"},
{"\nk:v", "", "\n", "k:v"},

View File

@ -10,6 +10,8 @@ import (
"strings"
"gitea.dev/modules/util"
"golang.org/x/mod/semver"
)
const (
@ -20,6 +22,7 @@ const (
var (
ErrInvalidStructure = util.NewInvalidArgumentErrorf("package has invalid structure")
ErrInvalidVersion = util.NewInvalidArgumentErrorf("package version is invalid")
ErrGoModFileTooLarge = util.NewInvalidArgumentErrorf("go.mod file is too large")
)
@ -54,6 +57,13 @@ func ParsePackage(r io.ReaderAt, size int64) (*Package, error) {
Name: strings.TrimSuffix(nameAndVersion, "@"+parts[1]),
Version: versionParts[0],
}
// the version is taken verbatim from the zip path and later written
// one per line into the @v/list proxy response, so it has to be a
// valid module version (no newlines or other stray characters)
if !semver.IsValid(p.Version) {
return nil, ErrInvalidVersion
}
}
if len(versionParts) > 1 {

View File

@ -59,6 +59,16 @@ func TestParsePackage(t *testing.T) {
assert.Equal(t, "module gitea.com/go-gitea/gitea", p.GoMod)
})
t.Run("InvalidVersion", func(t *testing.T) {
data := createArchive(map[string][]byte{
packageName + "@v1.0.0\nv99.0.0/go.mod": []byte("module " + packageName),
})
p, err := ParsePackage(data, int64(data.Len()))
assert.Nil(t, p)
assert.ErrorIs(t, err, ErrInvalidVersion)
})
t.Run("Valid", func(t *testing.T) {
data := createArchive(map[string][]byte{
packageName + "@" + packageVersion + "/subdir/go.mod": []byte("invalid"),

View File

@ -2205,10 +2205,10 @@
"repo.settings.trust_model.collaborator.desc": "Déanfar sínithe bailí ó chomhoibritheoirí an stórais seo a mharcáil mar \"iontaofa\", cibé acu a mheaitseálann siad an tiomnóir nó nach meaitseálann. Seachas sin, déanfar sínithe bailí a mharcáil mar \"neamhiontaofa\" má mheaitseálann an síniú an tiomnóir agus \"gan mheaitseáil\" mura bhfuil.",
"repo.settings.trust_model.committer": "Coimisitheoir",
"repo.settings.trust_model.committer.long": "Tiomnaithe: Sínithe muiníne a mheaitseálann tiomnóirí. Meaitseálann sé seo iompar GitHub agus cuirfidh sé iallach ar thiomnóirí atá sínithe ag Gitea Gitea a bheith mar an tiomnóir.",
"repo.settings.trust_model.committer.desc": "Ní mharcálfar sínithe bailí mar \"iontaofa\" ach amháin má mheaitseálann siad an tiomn, nó marcálfar iad mar \"gan mheaitseáil\". Cuireann sé seo iallach ar Gitea a bheith ina tiomnaí ar thiomnuithe sínithe, agus an tiomnaí iarbhír marcáilte mar Chomhúdaraithe ag: agus Co-thiomnaithe ag: leantóir sa tiomnú. Caithfidh eochair réamhshocraithe Gitea a bheith ag teacht le húsáideoir sa bhunachar sonraí.",
"repo.settings.trust_model.committer.desc": "Ní mharcálfar sínithe bailí mar \"iontaofa\" ach amháin má mheaitseálann siad an tiomnóir, nó marcálfar iad mar \"gan mheaitseáil\". Cuireann sé seo iallach ar Gitea a bheith ina thiomnóir ar thiomnuithe sínithe, agus an tiomnóir iarbhír marcáilte mar leantóir Co-authored-by: sa thiomnú. Caithfidh eochair réamhshocraithe Gitea a bheith ag teacht le húsáideoir sa bhunachar sonraí.",
"repo.settings.trust_model.collaboratorcommitter": "Comhoibritheo+Coimiteoir",
"repo.settings.trust_model.collaboratorcommitter.long": "Comhoibrí+Coiste: sínithe muiníne ó chomhoibrithe a mheaitseálann an tiomnóir",
"repo.settings.trust_model.collaboratorcommitter.desc": "Marcálfar sínithe bailí ó chomhoibritheoirí an stórais seo mar \"iontaofa\" má mheaitseálann siad an tiomn. Seachas sin, marcálfar sínithe bailí mar \"neamhiontaofa\" má mheaitseálann an síniú an tiomn agus \"gan mheaitseáil\" murach sin. Cuirfidh sé seo iallach ar Gitea a bheith marcáilte mar an tiomnaí ar thiomnuithe sínithe, agus an tiomnaí iarbhír marcáilte mar Chomhúdaraithe ag: agus Co-Tiomnaithe ag: leantóir sa tiomnú. Ní mór don eochair réamhshocraithe Gitea a bheith ag teacht le húsáideoir sa bhunachar sonraí.",
"repo.settings.trust_model.collaboratorcommitter.desc": "Marcálfar sínithe bailí ó chomhoibritheoirí an stórais seo mar \"iontaofa\" má mheaitseálann siad an tiomnóir. Seachas sin, marcálfar sínithe bailí mar \"neamhiontaofa\" má mheaitseálann an síniú an tiomnóir agus \"gan mheaitseáil\" murach sin. Cuirfidh sé seo iallach ar Gitea a bheith marcáilte mar an tiomnóir ar thiomnuithe sínithe, agus an tiomnóir iarbhír marcáilte mar leantóir Co-Authored-By: sa tiomnú. Ní mór don eochair réamhshocraithe Gitea a bheith ag teacht le húsáideoir sa bhunachar sonraí.",
"repo.settings.wiki_delete": "Scrios Sonraí Vicí",
"repo.settings.wiki_delete_desc": "Tá sonraí wiki stóras a scriosadh buan agus ní féidir iad a chur ar ais.",
"repo.settings.wiki_delete_notices_1": "- Scriosfaidh agus díchumasóidh sé seo an stóras vicí do %s go buan.",
@ -2599,6 +2599,9 @@
"repo.diff.review.reject": "Iarr athruithe",
"repo.diff.review.self_approve": "Ní féidir le húdair iarratais tarraing a n-iarratas tarraingthe féin a chead",
"repo.diff.committed_by": "tiomanta ag",
"repo.diff.coauthored_by": "comhúdaraithe ag",
"repo.commits.avatar_stack_and": "agus",
"repo.commits.avatar_stack_people": "%d duine",
"repo.diff.protected": "Cosanta",
"repo.diff.image.side_by_side": "Taobh le Taobh",
"repo.diff.image.swipe": "Scaoil",
@ -2862,6 +2865,14 @@
"org.teams.all_repositories_read_permission_desc": "Tugann an fhoireann seo rochtain do <strong>Léamh</strong> ar <strong>gach stórais</strong>: is féidir le baill amharc ar stórais agus iad a chlónáil.",
"org.teams.all_repositories_write_permission_desc": "Tugann an fhoireann seo rochtain do <strong>Scríobh</strong> ar <strong>gach stórais</strong>: is féidir le baill léamh ó stórais agus iad a bhrú chucu.",
"org.teams.all_repositories_admin_permission_desc": "Tugann an fhoireann seo rochtain <strong>Riarthóra</strong> ar <strong>gach stóras</strong>: is féidir le comhaltaí léamh, brú a dhéanamh agus comhoibritheoirí a chur le stórtha.",
"org.teams.visibility": "Infheictheacht",
"org.teams.visibility_private": "Príobháideach",
"org.teams.visibility_private_helper": "Le feiceáil ag baill foirne agus úinéirí eagraíochta amháin.",
"org.teams.visibility_limited": "Teoranta",
"org.teams.visibility_limited_helper": "Infheicthe ag gach ball den eagraíocht seo.",
"org.teams.visibility_public": "Poiblí",
"org.teams.visibility_public_helper": "Infheicthe ag aon úsáideoir atá sínithe isteach.",
"org.teams.owners_visibility_fixed": "Ní féidir infheictheacht fhoireann na nÚinéirí a athrú.",
"org.teams.invite.title": "Tugadh cuireadh duit dul isteach i bhfoireann <strong>%s</strong> san eagraíocht <strong>%s</strong>.",
"org.teams.invite.by": "Ar cuireadh ó %s",
"org.teams.invite.description": "Cliceáil ar an gcnaipe thíos le do thoil chun dul isteach san fhoireann.",
@ -3774,6 +3785,7 @@
"actions.runs.no_matching_online_runner_helper": "Gan aon reathaí ar líne a mheaitseáil le lipéad: %s",
"actions.runs.no_job_without_needs": "Caithfidh post amháin ar a laghad a bheith sa sreabhadh oibre gan spleáchas.",
"actions.runs.no_job": "Caithfidh post amháin ar a laghad a bheith sa sreabhadh oibre",
"actions.runs.invalid_reusable_workflow_uses": "Sreabhadh oibre in-athúsáidte neamhbhailí \"úsáidí\": %s",
"actions.runs.actor": "Aisteoir",
"actions.runs.status": "Stádas",
"actions.runs.actors_no_select": "Gach aisteoir",
@ -3794,13 +3806,17 @@
"actions.runs.view_workflow_file": "Féach ar chomhad sreabha oibre",
"actions.runs.summary": "Achoimre",
"actions.runs.all_jobs": "Gach post",
"actions.runs.job_summaries": "Achoimrí poist",
"actions.runs.expand_caller_jobs": "Taispeáin poist an ghlaoiteora sreabha oibre in-athúsáidte seo",
"actions.runs.collapse_caller_jobs": "Folaigh poist an ghlaoiteora sreabha oibre in-athúsáidte seo",
"actions.runs.attempt": "Iarracht",
"actions.runs.latest": "Is déanaí",
"actions.runs.latest_attempt": "An iarracht is déanaí",
"actions.runs.triggered_via": "Spreagtha trí %s",
"actions.runs.total_duration": "Fad iomlán:",
"actions.runs.rerun_triggered": "Athrith spreagtha",
"actions.runs.back_to_pull_request": "Ar ais chuig an iarratas tarraingthe",
"actions.runs.back_to_workflow": "Ar ais chuig an sreabhadh oibre",
"actions.runs.total_duration": "Fad iomlán",
"actions.runs.workflow_dependencies": "Spleáchais ar Shreabhadh Oibre",
"actions.runs.graph_jobs_count_1": "%d post",
"actions.runs.graph_jobs_count_n": "%d poist",

View File

@ -1,6 +1,6 @@
{
"type": "module",
"packageManager": "pnpm@11.5.2",
"packageManager": "pnpm@11.5.3",
"engines": {
"node": ">= 22.18.0",
"pnpm": ">= 11.0.0"

View File

@ -11,6 +11,7 @@ import (
api "gitea.dev/modules/structs"
"gitea.dev/services/context"
"gitea.dev/services/convert"
git_service "gitea.dev/services/git"
)
// CompareDiff compare two branches or commits
@ -18,8 +19,12 @@ func CompareDiff(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/compare/{basehead} repository repoCompareDiff
// ---
// summary: Get commit comparison information
// description: |
// By default returns JSON commit comparison information. The raw diff or patch can be
// requested with the `output` query parameter set to `diff` or `patch` respectively.
// produces:
// - application/json
// - text/plain
// parameters:
// - name: owner
// in: path
@ -33,9 +38,16 @@ func CompareDiff(ctx *context.APIContext) {
// required: true
// - name: basehead
// in: path
// description: compare two branches or commits
// description: compare two refs as `base...head` (or `base..head`); refs may be branches, tags, full or short SHAs, including branch names that contain slashes.
// type: string
// required: true
// - name: output
// in: query
// description: return the raw comparison as `diff` or `patch` instead of JSON
// type: string
// enum:
// - diff
// - patch
// responses:
// "200":
// "$ref": "#/responses/Compare"
@ -57,6 +69,16 @@ func CompareDiff(ctx *context.APIContext) {
}
defer closer()
// ?output=diff|patch returns the raw output, otherwise the JSON comparison is returned.
switch ctx.FormString("output") {
case "diff":
downloadCompareDiffOrPatch(ctx, compareInfo, false)
return
case "patch":
downloadCompareDiffOrPatch(ctx, compareInfo, true)
return
}
verification := ctx.FormString("verification") == "" || ctx.FormBool("verification")
files := ctx.FormString("files") == "" || ctx.FormBool("files")
@ -88,3 +110,20 @@ func CompareDiff(ctx *context.APIContext) {
Commits: apiCommits,
})
}
// downloadCompareDiffOrPatch writes a comparison's raw diff or patch to the response.
func downloadCompareDiffOrPatch(ctx *context.APIContext, compareInfo *git_service.CompareInfo, patch bool) {
ctx.Resp.Header().Set("Content-Type", "text/plain; charset=utf-8")
compareArg := compareInfo.BaseCommitID + compareInfo.CompareSeparator + compareInfo.HeadCommitID
var err error
if patch {
err = compareInfo.HeadGitRepo.GetPatch(compareArg, ctx.Resp)
} else {
err = compareInfo.HeadGitRepo.GetDiff(compareArg, ctx.Resp)
}
if err != nil {
ctx.APIErrorInternal(err)
return
}
}

View File

@ -201,12 +201,12 @@ func newComparePageInfo() *comparePageInfoType {
}
// parseCompareInfo parse compare info between two commit for preparing comparing references
func (cpi *comparePageInfoType) parseCompareInfo(ctx *context.Context) error {
func (cpi *comparePageInfoType) parseCompareInfo(ctx *context.Context, compareParam string) error {
baseRepo := ctx.Repo.Repository
fileOnly := ctx.FormBool("file-only")
// 1 Parse compare router param
compareReq := common.ParseCompareRouterParam(ctx.PathParam("*"))
compareReq := common.ParseCompareRouterParam(compareParam)
// remove the check when we support compare with carets
if compareReq.BaseOriRefSuffix != "" {
@ -545,7 +545,7 @@ func getBranchesAndTagsForRepo(ctx gocontext.Context, repo *repo_model.Repositor
// CompareDiff show different from one commit to another commit
func CompareDiff(ctx *context.Context) {
comparePageInfo := newComparePageInfo()
err := comparePageInfo.parseCompareInfo(ctx)
err := comparePageInfo.parseCompareInfo(ctx, ctx.PathParam("*"))
if errors.Is(err, util.ErrNotExist) || errors.Is(err, util.ErrInvalidArgument) {
ctx.NotFound(nil)
return
@ -605,6 +605,45 @@ func CompareDiff(ctx *context.Context) {
ctx.HTML(http.StatusOK, tplCompare)
}
// DownloadCompareDiff render a comparison's raw unified diff
func DownloadCompareDiff(ctx *context.Context) {
downloadCompareDiffOrPatch(ctx, false)
}
// DownloadComparePatch render a comparison as a git format-patch
func DownloadComparePatch(ctx *context.Context) {
downloadCompareDiffOrPatch(ctx, true)
}
func downloadCompareDiffOrPatch(ctx *context.Context, patch bool) {
// The route captures `basehead` separately so the `.diff`/`.patch` suffix is
// stripped from the catch-all `*` param parseCompareInfo would otherwise read.
cpi := newComparePageInfo()
if err := cpi.parseCompareInfo(ctx, ctx.PathParam("basehead")); err != nil {
if errors.Is(err, util.ErrNotExist) || errors.Is(err, util.ErrInvalidArgument) {
ctx.NotFound(nil)
} else {
ctx.ServerError("ParseCompareInfo", err)
}
return
}
ci := cpi.compareInfo
ctx.Resp.Header().Set("Content-Type", "text/plain; charset=utf-8")
compareArg := ci.BaseCommitID + ci.CompareSeparator + ci.HeadCommitID
var err error
if patch {
err = ci.HeadGitRepo.GetPatch(compareArg, ctx.Resp)
} else {
err = ci.HeadGitRepo.GetDiff(compareArg, ctx.Resp)
}
if err != nil {
ctx.ServerError("DownloadCompareDiffOrPatch", err)
return
}
}
func (cpi *comparePageInfoType) prepareCreatePullRequestPage(ctx *context.Context) {
ci := cpi.compareInfo
if cpi.allowCreatePull {

View File

@ -1310,7 +1310,7 @@ func CompareAndPullRequestPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.CreateIssueForm)
repo := ctx.Repo.Repository
comparePageInfo := newComparePageInfo()
err := comparePageInfo.parseCompareInfo(ctx)
err := comparePageInfo.parseCompareInfo(ctx, ctx.PathParam("*"))
if errors.Is(err, util.ErrNotExist) {
ctx.JSONErrorNotFound()
return

View File

@ -1269,9 +1269,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Get("/commit/*", context.RepoRefByType(git.RefTypeCommit), repo.TreeViewNodes)
})
m.Get("/compare", repo.MustBeNotEmpty, repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.CompareDiff)
m.Combo("/compare/*", repo.MustBeNotEmpty, repo.SetEditorconfigIfExists).
Get(repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.CompareDiff).
Post(reqSignIn, context.RepoMustNotBeArchived(), reqUnitPullsReader, repo.MustAllowPulls, web.Bind(forms.CreateIssueForm{}), repo.SetWhitespaceBehavior, repo.CompareAndPullRequestPost)
m.PathGroup("/compare/*", func(g *web.RouterPathGroup) {
g.MatchPath("GET", "/<basehead:*>.diff", repo.MustBeNotEmpty, repo.DownloadCompareDiff)
g.MatchPath("GET", "/<basehead:*>.patch", repo.MustBeNotEmpty, repo.DownloadComparePatch)
g.MatchPath("GET", "/<*:*>", repo.MustBeNotEmpty, repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.CompareDiff)
g.MatchPath("POST", "/<*:*>", repo.MustBeNotEmpty, repo.SetEditorconfigIfExists, reqSignIn, context.RepoMustNotBeArchived(), reqUnitPullsReader, repo.MustAllowPulls, web.Bind(forms.CreateIssueForm{}), repo.SetWhitespaceBehavior, repo.CompareAndPullRequestPost)
})
m.Get("/pulls/new/*", repo.PullsNewRedirect)
}, optSignIn, context.RepoAssignment, reqUnitCodeReader)
// end "/{username}/{reponame}": repo code: find, compare, list

View File

@ -4,14 +4,15 @@
package pull
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"regexp"
"slices"
"strings"
"time"
"unicode"
"unicode/utf8"
"gitea.dev/models/db"
@ -767,8 +768,6 @@ func CloseRepoBranchesPulls(ctx context.Context, doer *user_model.User, repo *re
return errors.Join(errs...)
}
var commitMessageTrailersPattern = regexp.MustCompile(`(?:^|\n\n)(?:[\w-]+[ \t]*:[^\n]+\n*(?:[ \t]+[^\n]+\n*)*)+$`)
// GetSquashMergeCommitMessages returns the commit messages between head and merge base (if there is one)
func GetSquashMergeCommitMessages(ctx context.Context, pr *issues_model.PullRequest) string {
if err := pr.LoadIssue(ctx); err != nil {
@ -819,54 +818,44 @@ func GetSquashMergeCommitMessages(ctx context.Context, pr *issues_model.PullRequ
return ""
}
posterSig := pr.Issue.Poster.NewGitSig().String()
mergeMessage := strings.TrimSpace(pr.Issue.Content) // use PR's title and description as squash commit message
if setting.Repository.PullRequest.PopulateSquashCommentWithCommitMessages {
mergeMessage = formatSquashMergeCommitMessages(limitedCommits) // use PR's commit messages as squash commit message
}
coAuthors := collectSquashMergeCommitCoAuthors(ctx, gitRepo, pr, headCommitRef, mergeBaseRef, limit, limitedCommits)
return buildSquashMergeCommitMessages(mergeMessage, coAuthors)
}
uniqueAuthors := make(container.Set[string])
authors := make([]string, 0, len(limitedCommits))
stringBuilder := strings.Builder{}
if !setting.Repository.PullRequest.PopulateSquashCommentWithCommitMessages {
// use PR's title and description as squash commit message
message := strings.TrimSpace(pr.Issue.Content)
stringBuilder.WriteString(message)
if stringBuilder.Len() > 0 {
stringBuilder.WriteRune('\n')
if !commitMessageTrailersPattern.MatchString(message) {
// TODO: this trailer check doesn't work with the separator line added below for the co-authors
stringBuilder.WriteRune('\n')
}
}
} else {
// use PR's commit messages as squash commit message
// commits list is in reverse chronological order
maxMsgSize := setting.Repository.PullRequest.DefaultMergeMessageSize
for _, commit := range slices.Backward(limitedCommits) {
msg := strings.TrimSpace(commit.MessageUTF8())
if msg == "" {
continue
}
// This format follows GitHub's squash commit message style,
// even if there are other "* " in the commit message body, they are written as-is.
// Maybe, ideally, we should indent those lines too.
_, _ = fmt.Fprintf(&stringBuilder, "* %s\n\n", msg)
if maxMsgSize > 0 && stringBuilder.Len() >= maxMsgSize {
tmp := stringBuilder.String()
wasValidUtf8 := utf8.ValidString(tmp)
tmp = tmp[:maxMsgSize] + "..."
if wasValidUtf8 {
// If the message was valid UTF-8 before truncation, ensure it remains valid after truncation
// For non-utf8 messages, we can't do much about it, end users should use utf-8 as much as possible
tmp = strings.ToValidUTF8(tmp, "")
}
stringBuilder.Reset()
stringBuilder.WriteString(tmp)
break
}
}
func buildSquashMergeCommitMessages(mergeMessage string, coAuthors []string) string {
if len(coAuthors) == 0 {
return mergeMessage
}
// collect co-authors
msgContent, msgSep, msgTrailer := git.CommitMessageSplitTrailer(mergeMessage)
if (msgSep == "" || msgSep == "\n\n") && msgTrailer == "" {
msgContent = strings.TrimRightFunc(msgContent, unicode.IsSpace)
msgSep = "\n\n---------\n\n"
}
var sb strings.Builder
sb.WriteString(msgContent)
sb.WriteString(msgSep)
if msgTrailer = strings.TrimSpace(msgTrailer); msgTrailer != "" {
sb.WriteString(msgTrailer)
sb.WriteRune('\n')
}
for _, author := range coAuthors {
sb.WriteString(git.CoAuthoredByTrailer + ": ")
sb.WriteString(author)
sb.WriteRune('\n')
}
return sb.String()
}
func collectSquashMergeCommitCoAuthors(ctx context.Context, gitRepo *git.Repository, pr *issues_model.PullRequest, headCommitRef, mergeBaseRef git.RefName, limitFirst int, limitedCommits []*git.Commit) []string {
posterSig := pr.Issue.Poster.NewGitSig().String()
uniqueAuthors := make(container.Set[string])
authors := make([]string, 0, len(limitedCommits))
for _, commit := range limitedCommits {
authorString := commit.Author.String()
if uniqueAuthors.Add(authorString) && authorString != posterSig {
@ -880,14 +869,14 @@ func GetSquashMergeCommitMessages(ctx context.Context, pr *issues_model.PullRequ
}
// collect the remaining authors
if limit >= 0 && setting.Repository.PullRequest.DefaultMergeMessageAllAuthors {
skip := limit
limit = 30
if limitFirst >= 0 && setting.Repository.PullRequest.DefaultMergeMessageAllAuthors {
skip := limitFirst
batchLimit := 30
for {
commits, err := gitRepo.CommitsBetween(headCommitRef, mergeBaseRef, limit, skip)
commits, err := gitRepo.CommitsBetween(headCommitRef, mergeBaseRef, batchLimit, skip)
if err != nil {
log.Error("Unable to get commits between: %s %s Error: %v", pr.HeadBranch, pr.MergeBase, err)
return ""
return authors
}
if len(commits) == 0 {
break
@ -901,22 +890,46 @@ func GetSquashMergeCommitMessages(ctx context.Context, pr *issues_model.PullRequ
}
}
}
skip += limit
skip += batchLimit
}
}
return authors
}
func formatSquashMergeCommitMessages(commits []*git.Commit) string {
maxMsgSize := setting.Repository.PullRequest.DefaultMergeMessageSize
sb := &bytes.Buffer{}
// commits list is in reverse chronological order
for _, commit := range slices.Backward(commits) {
msg := strings.TrimSpace(commit.MessageUTF8())
if msg == "" {
continue
}
// This format follows GitHub's squash commit message style,
// even if there are other "* " in the commit message body, they are written as-is.
// Maybe, ideally, we should indent those lines too.
_, _ = fmt.Fprintf(sb, "* %s\n\n", msg)
if maxMsgSize > 0 && sb.Len() >= maxMsgSize {
break
}
}
if stringBuilder.Len() > 0 && len(authors) > 0 {
// TODO: this separator line doesn't work with the trailer check (commitMessageTrailersPattern) above
stringBuilder.WriteString("---------\n\n")
buf := bytes.TrimSpace(sb.Bytes())
if maxMsgSize > 0 && len(buf) > maxMsgSize {
buf = buf[:maxMsgSize]
for {
r, sz := utf8.DecodeLastRune(buf)
if r == utf8.RuneError && sz == 1 {
buf = buf[:len(buf)-1]
continue
}
break
}
buf = append(buf, '.', '.', '.')
}
for _, author := range authors {
stringBuilder.WriteString(git.CoAuthoredByTrailer + ": ")
stringBuilder.WriteString(author)
stringBuilder.WriteRune('\n')
}
return stringBuilder.String()
buf = append(buf, '\n', '\n')
return util.UnsafeBytesToString(buf)
}
// GetIssuesAllCommitStatus returns a map of issue ID to a list of all statuses for the most recent commit as well as a map of issue ID to only the commit's latest status

View File

@ -11,28 +11,33 @@ import (
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/models/unittest"
"gitea.dev/modules/git"
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
)
// TODO TestPullRequest_PushToBaseRepo
func TestPullRequest_CommitMessageTrailersPattern(t *testing.T) {
// Not a valid trailer section
assert.False(t, commitMessageTrailersPattern.MatchString(""))
assert.False(t, commitMessageTrailersPattern.MatchString("No trailer."))
assert.False(t, commitMessageTrailersPattern.MatchString("Signed-off-by: Bob <bob@example.com>\nNot a trailer due to following text."))
assert.False(t, commitMessageTrailersPattern.MatchString("Message body not correctly separated from trailer section by empty line.\nSigned-off-by: Bob <bob@example.com>"))
// Valid trailer section
assert.True(t, commitMessageTrailersPattern.MatchString("Signed-off-by: Bob <bob@example.com>"))
assert.True(t, commitMessageTrailersPattern.MatchString("Signed-off-by: Bob <bob@example.com>\nOther-Trailer: Value"))
assert.True(t, commitMessageTrailersPattern.MatchString("Message body correctly separated from trailer section by empty line.\n\nSigned-off-by: Bob <bob@example.com>"))
assert.True(t, commitMessageTrailersPattern.MatchString("Multiple trailers.\n\nSigned-off-by: Bob <bob@example.com>\nOther-Trailer: Value"))
assert.True(t, commitMessageTrailersPattern.MatchString("Newline after trailer section.\n\nSigned-off-by: Bob <bob@example.com>\n"))
assert.True(t, commitMessageTrailersPattern.MatchString("No space after colon is accepted.\n\nSigned-off-by:Bob <bob@example.com>"))
assert.True(t, commitMessageTrailersPattern.MatchString("Additional whitespace is accepted.\n\nSigned-off-by \t : \tBob <bob@example.com> "))
assert.True(t, commitMessageTrailersPattern.MatchString("Folded value.\n\nFolded-trailer: This is\n a folded\n trailer value\nOther-Trailer: Value"))
func TestPullRequest_FormatSquashMergeCommitMessages(t *testing.T) {
oldest := &git.Commit{CommitMessage: git.CommitMessage{MessageRaw: "commit msg 1"}}
newest := &git.Commit{CommitMessage: git.CommitMessage{MessageRaw: "commit msg 2\n\nCommit description."}}
defer test.MockVariableValue(&setting.Repository.PullRequest.DefaultMergeMessageSize, 0)()
assert.Equal(t, "* commit msg 1\n\n* commit msg 2\n\nCommit description.\n\n", formatSquashMergeCommitMessages([]*git.Commit{newest, oldest}))
utf8Msg := &git.Commit{CommitMessage: git.CommitMessage{MessageRaw: "🌞"}}
setting.Repository.PullRequest.DefaultMergeMessageSize = 3
assert.Equal(t, "* ...\n\n", formatSquashMergeCommitMessages([]*git.Commit{utf8Msg}))
setting.Repository.PullRequest.DefaultMergeMessageSize = 4
assert.Equal(t, "* ...\n\n", formatSquashMergeCommitMessages([]*git.Commit{utf8Msg}))
setting.Repository.PullRequest.DefaultMergeMessageSize = 5
assert.Equal(t, "* ...\n\n", formatSquashMergeCommitMessages([]*git.Commit{utf8Msg}))
setting.Repository.PullRequest.DefaultMergeMessageSize = 6
assert.Equal(t, "* 🌞\n\n", formatSquashMergeCommitMessages([]*git.Commit{utf8Msg}))
}
func TestPullRequest_GetDefaultMergeMessage_InternalTracker(t *testing.T) {
@ -88,3 +93,27 @@ func TestPullRequest_GetDefaultMergeMessage_ExternalTracker(t *testing.T) {
assert.Equal(t, "Merge pull request 'issue3' (#3) from user2/repo2:branch2 into master", mergeMessage)
}
func TestBuildSquashMergeCommitMessages(t *testing.T) {
cases := []struct {
msg string
coAuthors []string
expected string
}{
{"title", nil, "title"},
{"title", []string{"the-user"}, "title\n\n---------\n\nCo-authored-by: the-user\n"},
{"title\n\n", []string{"the-user"}, "title\n\n---------\n\nCo-authored-by: the-user\n"},
{"title\n\nKey: val", []string{"the-user"}, "title\n\nKey: val\nCo-authored-by: the-user\n"},
{"title\n\n----\nKey: val", []string{"the-user"}, "title\n\n----\nKey: val\nCo-authored-by: the-user\n"},
{"title\n\n----\nKey: val\n\n", []string{"the-user"}, "title\n\n----\nKey: val\nCo-authored-by: the-user\n"},
{"title\n\nbody", nil, "title\n\nbody"},
{"title\n\nbody", []string{"the-user"}, "title\n\nbody\n\n---------\n\nCo-authored-by: the-user\n"},
{"title\n\nbody\n\nKey: val", []string{"the-user"}, "title\n\nbody\n\nKey: val\nCo-authored-by: the-user\n"},
{"title\n\nbody\n\n----\nKey: val", []string{"the-user"}, "title\n\nbody\n\n----\nKey: val\nCo-authored-by: the-user\n"},
}
for _, c := range cases {
msg := buildSquashMergeCommitMessages(c.msg, c.coAuthors)
assert.Equal(t, c.expected, msg, "msg: %s", c.msg)
}
}

View File

@ -8108,8 +8108,10 @@
},
"/repos/{owner}/{repo}/compare/{basehead}": {
"get": {
"description": "By default returns JSON commit comparison information. The raw diff or patch can be\nrequested with the `output` query parameter set to `diff` or `patch` respectively.\n",
"produces": [
"application/json"
"application/json",
"text/plain"
],
"tags": [
"repository"
@ -8133,10 +8135,20 @@
},
{
"type": "string",
"description": "compare two branches or commits",
"description": "compare two refs as `base...head` (or `base..head`); refs may be branches, tags, full or short SHAs, including branch names that contain slashes.",
"name": "basehead",
"in": "path",
"required": true
},
{
"enum": [
"diff",
"patch"
],
"type": "string",
"description": "return the raw comparison as `diff` or `patch` instead of JSON",
"name": "output",
"in": "query"
}
],
"responses": {

View File

@ -19468,6 +19468,7 @@
},
"/repos/{owner}/{repo}/compare/{basehead}": {
"get": {
"description": "By default returns JSON commit comparison information. The raw diff or patch can be\nrequested with the `output` query parameter set to `diff` or `patch` respectively.\n",
"operationId": "repoCompareDiff",
"parameters": [
{
@ -19489,13 +19490,25 @@
}
},
{
"description": "compare two branches or commits",
"description": "compare two refs as `base...head` (or `base..head`); refs may be branches, tags, full or short SHAs, including branch names that contain slashes.",
"in": "path",
"name": "basehead",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "return the raw comparison as `diff` or `patch` instead of JSON",
"in": "query",
"name": "output",
"schema": {
"enum": [
"diff",
"patch"
],
"type": "string"
}
}
],
"responses": {

View File

@ -6,6 +6,7 @@ package integration
import (
"net/http"
"net/url"
"strings"
"testing"
auth_model "gitea.dev/models/auth"
@ -62,3 +63,113 @@ func TestAPICompareBranches(t *testing.T) {
})
})
}
func TestAPIDownloadCompareDiffOrPatch(t *testing.T) {
onGiteaRun(t, func(t *testing.T, _ *url.URL) {
session := loginUser(t, "user2")
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
t.Run("BranchToBranchDiff", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo20/compare/add-csv...remove-files-b?output=diff").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "text/plain; charset=utf-8", resp.Header().Get("Content-Type"))
body := resp.Body.String()
assert.Contains(t, body, "diff --git ")
})
t.Run("BranchToBranchPatch", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo20/compare/add-csv...remove-files-b?output=patch").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "text/plain; charset=utf-8", resp.Header().Get("Content-Type"))
body := resp.Body.String()
assert.True(t, strings.HasPrefix(body, "From "), "patch output should start with a format-patch header, got: %q", body[:min(40, len(body))])
})
t.Run("CommitToCommitDiff", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo20/compare/808038d2f71b0ab02099...c8e31bc7688741a5287f?output=diff").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "diff --git ")
})
t.Run("BranchToCommitDiff", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// 8babce96... is the head of remove-files-b; pairing it with add-csv guarantees a non-empty diff.
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo20/compare/add-csv...8babce967f21b9dfa6987f943b91093dac58a4f0?output=diff").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "diff --git ")
})
t.Run("TwoDotSeparator", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo20/compare/add-csv..remove-files-b?output=diff").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "diff --git ")
})
t.Run("SlashedBranchName", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// user2/repo1's `feature/1` branch contains a slash; the route must match it
// without URL-encoding. master and feature/1 happen to share a SHA in the fixture,
// so we only assert the route resolves (200 OK) rather than checking diff content.
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/compare/master...feature/1?output=diff").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "text/plain; charset=utf-8", resp.Header().Get("Content-Type"))
})
t.Run("UnknownOutputReturnsJSON", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// Only "diff"/"patch" switch to raw output; any other value falls through to JSON.
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo20/compare/add-csv...remove-files-b?output=foo").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
apiResp := DecodeJSON(t, resp, &api.Compare{})
assert.Equal(t, 2, apiResp.TotalCommits)
})
t.Run("SingleRefImplicitBase", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// No `...`/`..` separator: parseCompareInfo defaults the base to the
// repo's PR target branch (master for repo20) and compares it against
// the given head.
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo20/compare/add-csv?output=diff").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "text/plain; charset=utf-8", resp.Header().Get("Content-Type"))
assert.Contains(t, resp.Body.String(), "diff --git ")
})
t.Run("PrivateRepoAnonymous", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// repo16 is private; an unauthenticated request must not leak its existence.
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo16/compare/master...good-sign?output=diff")
MakeRequest(t, req, http.StatusNotFound)
})
t.Run("CrossRepoFork", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
user13 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 13})
repo11 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 11})
user13Sess := loginUser(t, "user13")
user13Token := getTokenForLoggedInUser(t, user13Sess, auth_model.AccessTokenScopeWriteRepository)
_, err := createFileInBranch(user13, repo11, createFileInBranchOptions{OldBranch: "master", NewBranch: "cross-repo-diff"}, map[string]string{"hello.txt": "hi\n"})
require.NoError(t, err)
req := NewRequest(t, "GET", "/api/v1/repos/user12/repo10/compare/master...user13:cross-repo-diff?output=diff").AddTokenAuth(user13Token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "text/plain; charset=utf-8", resp.Header().Get("Content-Type"))
assert.Contains(t, resp.Body.String(), "diff --git ")
})
})
}

View File

@ -167,6 +167,53 @@ Hello from 2
assert.Equal(t, 0, htmlDoc.doc.Find(".pullrequest-form").Length())
}
func TestCompareDownloadDiffOrPatch(t *testing.T) {
defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user2")
t.Run("BranchToBranchDiff", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo20/compare/add-csv...remove-files-b.diff")
resp := session.MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "text/plain; charset=utf-8", resp.Header().Get("Content-Type"))
assert.Contains(t, resp.Body.String(), "diff --git ")
})
t.Run("BranchToBranchPatch", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo20/compare/add-csv...remove-files-b.patch")
resp := session.MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "text/plain; charset=utf-8", resp.Header().Get("Content-Type"))
assert.True(t, strings.HasPrefix(resp.Body.String(), "From "), "patch output should start with a format-patch header")
})
t.Run("SingleRefImplicitBase", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo20/compare/add-csv.diff")
resp := session.MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "diff --git ")
})
t.Run("InvalidBaseRef", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo20/compare/does-not-exist...remove-files-b.diff")
session.MakeRequest(t, req, http.StatusNotFound)
})
t.Run("PrivateRepoAnonymous", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// repo16 is private; an unauthenticated request must not leak its existence.
req := NewRequest(t, "GET", "/user2/repo16/compare/master...good-sign.diff")
MakeRequest(t, req, http.StatusNotFound)
})
}
func TestCompareCodeExpand(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})

View File

@ -1272,7 +1272,7 @@ Commit description.
commitMessage: `loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong message`,
},
},
expectedMessage: `* looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo...`,
expectedMessage: "* looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo...\n\n",
},
{
name: "Test Co-authored-by",