mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-07 12:02:20 +00:00
feat(api): support ref suffixes in compare (#38148)
Compare API requests with a `^` or `~N` revision suffix (for example `compare/main...feature^`) were rejected with `400 Unsupported comparison syntax: ref with suffix`. The fix resolves the suffix to a commit before comparing, so `base...head^` and `~N` work on either side, the same as git. Only `^`/`~N` navigation is resolved. Pull request creation still requires plain branch refs, and the web compare page keeps rejecting suffixes since its branch selectors need separate UI work. Closes #33943
This commit is contained in:
@@ -38,7 +38,7 @@ func CompareDiff(ctx *context.APIContext) {
|
||||
// required: true
|
||||
// - name: basehead
|
||||
// in: path
|
||||
// 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.
|
||||
// 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), optionally with a `^` or `~N` revision suffix.
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: output
|
||||
@@ -51,6 +51,8 @@ func CompareDiff(ctx *context.APIContext) {
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/Compare"
|
||||
// "400":
|
||||
// "$ref": "#/responses/error"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
|
||||
@@ -1087,12 +1087,6 @@ func parseCompareInfo(ctx *context.APIContext, compareParam string) (result *git
|
||||
baseRepo := ctx.Repo.Repository
|
||||
compareReq := common.ParseCompareRouterParam(compareParam)
|
||||
|
||||
// remove the check when we support compare with carets
|
||||
if compareReq.BaseOriRefSuffix != "" {
|
||||
ctx.APIError(http.StatusBadRequest, "Unsupported comparison syntax: ref with suffix")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
_, headRepo, err := common.GetHeadOwnerAndRepo(ctx, baseRepo, compareReq)
|
||||
switch {
|
||||
case errors.Is(err, util.ErrInvalidArgument):
|
||||
@@ -1152,10 +1146,18 @@ func parseCompareInfo(ctx *context.APIContext, compareParam string) (result *git
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
baseRef := ctx.Repo.GitRepo.UnstableGuessRefByShortName(util.IfZero(compareReq.BaseOriRef, baseRepo.GetPullRequestTargetBranch(ctx)))
|
||||
headRef := headGitRepo.UnstableGuessRefByShortName(util.IfZero(compareReq.HeadOriRef, headRepo.DefaultBranch))
|
||||
baseRef, err := common.ResolveRefWithSuffix(ctx.Repo.GitRepo, util.IfZero(compareReq.BaseOriRef, baseRepo.GetPullRequestTargetBranch(ctx)), compareReq.BaseOriRefSuffix)
|
||||
if err != nil {
|
||||
ctx.APIErrorAuto(err)
|
||||
return nil, nil
|
||||
}
|
||||
headRef, err := common.ResolveRefWithSuffix(headGitRepo, util.IfZero(compareReq.HeadOriRef, headRepo.DefaultBranch), compareReq.HeadOriRefSuffix)
|
||||
if err != nil {
|
||||
ctx.APIErrorAuto(err)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
log.Trace("Repo path: %q, base ref: %q->%q, head ref: %q->%q", ctx.Repo.Repository.RelativePath(), compareReq.BaseOriRef, baseRef, compareReq.HeadOriRef, headRef)
|
||||
log.Trace("Repo path: %q, base ref: %q->%q, head ref: %q->%q", ctx.Repo.Repository.RelativePath(), compareReq.BaseOriRef+compareReq.BaseOriRefSuffix, baseRef, compareReq.HeadOriRef+compareReq.HeadOriRefSuffix, headRef)
|
||||
|
||||
baseRefValid := baseRef.IsBranch() || baseRef.IsTag() || git.IsStringLikelyCommitID(git.ObjectFormatFromName(ctx.Repo.Repository.ObjectFormatName), baseRef.ShortName())
|
||||
headRefValid := headRef.IsBranch() || headRef.IsTag() || git.IsStringLikelyCommitID(git.ObjectFormatFromName(headRepo.ObjectFormatName), headRef.ShortName())
|
||||
|
||||
@@ -5,7 +5,9 @@ package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
@@ -19,9 +21,10 @@ type CompareRouterReq struct {
|
||||
|
||||
CompareSeparator string
|
||||
|
||||
HeadOwner string
|
||||
HeadRepoName string
|
||||
HeadOriRef string
|
||||
HeadOwner string
|
||||
HeadRepoName string
|
||||
HeadOriRef string
|
||||
HeadOriRefSuffix string
|
||||
}
|
||||
|
||||
func (cr *CompareRouterReq) DirectComparison() bool {
|
||||
@@ -79,9 +82,11 @@ func ParseCompareRouterParam(routerParam string) *CompareRouterReq {
|
||||
sep = ".."
|
||||
basePart, headPart, ok = strings.Cut(routerParam, sep)
|
||||
if !ok {
|
||||
headOwnerName, headRepoName, headRef := parseHead(routerParam)
|
||||
headOwnerName, headRepoName, headOriRef := parseHead(routerParam)
|
||||
headOriRef, headOriRefSuffix := git.ParseRefSuffix(headOriRef)
|
||||
return &CompareRouterReq{
|
||||
HeadOriRef: headRef,
|
||||
HeadOriRef: headOriRef,
|
||||
HeadOriRefSuffix: headOriRefSuffix,
|
||||
HeadOwner: headOwnerName,
|
||||
HeadRepoName: headRepoName,
|
||||
CompareSeparator: "...",
|
||||
@@ -92,9 +97,36 @@ func ParseCompareRouterParam(routerParam string) *CompareRouterReq {
|
||||
ci := &CompareRouterReq{CompareSeparator: sep}
|
||||
ci.BaseOriRef, ci.BaseOriRefSuffix = git.ParseRefSuffix(basePart)
|
||||
ci.HeadOwner, ci.HeadRepoName, ci.HeadOriRef = parseHead(headPart)
|
||||
ci.HeadOriRef, ci.HeadOriRefSuffix = git.ParseRefSuffix(ci.HeadOriRef)
|
||||
return ci
|
||||
}
|
||||
|
||||
// validRefSuffix matches only ^/~ ancestry navigation. The ^{...}, @{...} and :path forms address
|
||||
// other objects (trees, blobs) or reflog/upstream state that compare does not resolve, so they are rejected.
|
||||
var validRefSuffix = sync.OnceValue(func() *regexp.Regexp {
|
||||
return regexp.MustCompile(`^(?:[~^][0-9]*)+$`)
|
||||
})
|
||||
|
||||
// ResolveRefWithSuffix resolves oriRef plus an optional revision suffix (^, ~N) to a RefName.
|
||||
// A nil error guarantees a usable RefName: an unsupported suffix yields an invalid-argument error
|
||||
// and an unresolvable ref yields a not-found error.
|
||||
func ResolveRefWithSuffix(gitRepo *git.Repository, oriRef, refSuffix string) (git.RefName, error) {
|
||||
if refSuffix == "" {
|
||||
if refName := gitRepo.UnstableGuessRefByShortName(oriRef); refName != "" {
|
||||
return refName, nil
|
||||
}
|
||||
return "", util.NewNotExistErrorf("ref %q does not exist", oriRef)
|
||||
}
|
||||
if !validRefSuffix().MatchString(refSuffix) {
|
||||
return "", util.NewInvalidArgumentErrorf("unsupported ref suffix %q", refSuffix)
|
||||
}
|
||||
commit, err := gitRepo.GetCommit(oriRef + refSuffix)
|
||||
if err != nil {
|
||||
return "", util.NewNotExistErrorf("ref %q does not exist", oriRef+refSuffix)
|
||||
}
|
||||
return git.RefNameFromCommit(commit.ID.String()), nil
|
||||
}
|
||||
|
||||
// maxForkTraverseLevel defines the maximum levels to traverse when searching for the head repository.
|
||||
const maxForkTraverseLevel = 10
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ package common
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -97,9 +99,56 @@ func TestCompareRouterReq(t *testing.T) {
|
||||
HeadOriRef: "develop",
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "main...develop^",
|
||||
CompareRouterReq: &CompareRouterReq{
|
||||
BaseOriRef: "main",
|
||||
CompareSeparator: "...",
|
||||
HeadOriRef: "develop",
|
||||
HeadOriRefSuffix: "^",
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "main~2...develop",
|
||||
CompareRouterReq: &CompareRouterReq{
|
||||
BaseOriRef: "main",
|
||||
BaseOriRefSuffix: "~2",
|
||||
CompareSeparator: "...",
|
||||
HeadOriRef: "develop",
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "main...lunny/forked_repo:develop~3",
|
||||
CompareRouterReq: &CompareRouterReq{
|
||||
BaseOriRef: "main",
|
||||
CompareSeparator: "...",
|
||||
HeadOwner: "lunny",
|
||||
HeadRepoName: "forked_repo",
|
||||
HeadOriRef: "develop",
|
||||
HeadOriRefSuffix: "~3",
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "develop^",
|
||||
CompareRouterReq: &CompareRouterReq{
|
||||
CompareSeparator: "...",
|
||||
HeadOriRef: "develop",
|
||||
HeadOriRefSuffix: "^",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
assert.Equal(t, c.CompareRouterReq, ParseCompareRouterParam(c.input), "input: %s", c.input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRefWithSuffix(t *testing.T) {
|
||||
// The ^{...}, @{...} and :path forms address non-commit objects or reflog state, so they are
|
||||
// rejected before any repository access and a nil repo is fine here.
|
||||
for _, refSuffix := range []string{"^{/Add}", "^{commit}", "@{upstream}", "~1:path"} {
|
||||
ref, err := ResolveRefWithSuffix(nil, "branch", refSuffix)
|
||||
assert.ErrorIs(t, err, util.ErrInvalidArgument, "suffix %q", refSuffix)
|
||||
assert.Empty(t, ref, "suffix %q", refSuffix)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,11 +208,6 @@ func (cpi *comparePageInfoType) parseCompareInfo(ctx *context.Context, comparePa
|
||||
// 1 Parse compare router param
|
||||
compareReq := common.ParseCompareRouterParam(compareParam)
|
||||
|
||||
// remove the check when we support compare with carets
|
||||
if compareReq.BaseOriRefSuffix != "" {
|
||||
return util.NewInvalidArgumentErrorf("unsupported comparison syntax: ref with suffix")
|
||||
}
|
||||
|
||||
// 2 get repository and owner for head
|
||||
headOwner, headRepo, err := common.GetHeadOwnerAndRepo(ctx, baseRepo, compareReq)
|
||||
if err != nil {
|
||||
@@ -241,18 +236,18 @@ func (cpi *comparePageInfoType) parseCompareInfo(ctx *context.Context, comparePa
|
||||
baseRefName := util.IfZero(compareReq.BaseOriRef, baseRepo.GetPullRequestTargetBranch(ctx))
|
||||
headRefName := util.IfZero(compareReq.HeadOriRef, headRepo.DefaultBranch)
|
||||
|
||||
baseRef := ctx.Repo.GitRepo.UnstableGuessRefByShortName(baseRefName)
|
||||
if baseRef == "" {
|
||||
return util.NewNotExistErrorf("no base ref: %s", baseRefName)
|
||||
baseRef, err := common.ResolveRefWithSuffix(ctx.Repo.GitRepo, baseRefName, compareReq.BaseOriRefSuffix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
headGitRepo, err := gitrepo.RepositoryFromRequestContextOrOpen(ctx, headRepo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
headRef := headGitRepo.UnstableGuessRefByShortName(headRefName)
|
||||
if headRef == "" {
|
||||
return util.NewNotExistErrorf("no head ref: %s", headRefName)
|
||||
headRef, err := common.ResolveRefWithSuffix(headGitRepo, headRefName, compareReq.HeadOriRefSuffix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx.Data["BaseName"] = baseRepo.OwnerName
|
||||
|
||||
Reference in New Issue
Block a user