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:
Eyüp Can Akman
2026-06-24 08:38:02 +03:00
committed by GitHub
parent 59d4825a95
commit ef927f9fa3
11 changed files with 246 additions and 34 deletions
+70
View File
@@ -14,7 +14,10 @@ import (
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/test"
"gitea.dev/modules/util"
"gitea.dev/routers/common"
repo_service "gitea.dev/services/repository"
"gitea.dev/tests"
@@ -135,6 +138,73 @@ func TestCompareBranches(t *testing.T) {
inspectCompare(t, htmlDoc, diffCount, diffChanges)
}
func TestCompareWithRefSuffix(t *testing.T) {
defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user2")
// remove-files-b^ resolves to the tip's parent, so the test.txt added by the tip is excluded
req := NewRequest(t, "GET", "/user2/repo20/compare/add-csv...remove-files-b^")
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
inspectCompare(t, htmlDoc, 2, []string{"link_hi", "test.csv"})
// a suffix resolves to a commit rather than a branch, so the page offers no pull request to create
assert.Equal(t, 0, htmlDoc.doc.Find(".pullrequest-form").Length())
// the same suffix on the direct ".." comparison resolves to the same commit
req = NewRequest(t, "GET", "/user2/repo20/compare/add-csv..remove-files-b^")
resp = session.MakeRequest(t, req, http.StatusOK)
htmlDoc = NewHTMLParser(t, resp.Body)
inspectCompare(t, htmlDoc, 2, []string{"link_hi", "test.csv"})
// a ~N suffix on the base side resolves and renders the compare page, but also
// resolves to a commit rather than a branch, so no pull request form is offered
req = NewRequest(t, "GET", "/user2/repo20/compare/add-csv~1...remove-files-b")
resp = session.MakeRequest(t, req, http.StatusOK)
assert.True(t, test.IsNormalPageCompleted(resp.Body.String()))
htmlDoc = NewHTMLParser(t, resp.Body)
assert.Equal(t, 0, htmlDoc.doc.Find(".pullrequest-form").Length())
// the web handler folds an unsupported (^{...}) and an unresolvable (~50) suffix alike into 404
for _, basehead := range []string{
"add-csv...remove-files-b~50",
"add-csv...remove-files-b^{/Add}",
"add-csv^{/Add}...remove-files-b",
} {
req = NewRequest(t, "GET", "/user2/repo20/compare/"+basehead).SetHeader("Accept", "text/html")
resp = session.MakeRequest(t, req, http.StatusNotFound)
assert.True(t, test.IsNormalPageCompleted(resp.Body.String()))
}
}
func TestResolveRefWithSuffixContract(t *testing.T) {
defer tests.PrepareTestEnv(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 31})
gitRepo, err := gitrepo.OpenRepository(t.Context(), repo)
require.NoError(t, err)
defer gitRepo.Close()
// a nil error guarantees a usable RefName
ref, err := common.ResolveRefWithSuffix(gitRepo, "add-csv", "^")
require.NoError(t, err)
assert.NotEmpty(t, ref)
// a ref resolved with a suffix must be a commit SHA, not a branch ref
// (branch refs would break "New Pull Request" logic)
assert.False(t, ref.IsBranch(), "ref with suffix must not resolve to a branch")
// a missing ref and an unresolvable suffix both report not-found instead of an empty RefName
for _, tc := range []struct{ oriRef, suffix string }{
{"does-not-exist", ""},
{"add-csv", "~50"},
} {
ref, err := common.ResolveRefWithSuffix(gitRepo, tc.oriRef, tc.suffix)
assert.ErrorIs(t, err, util.ErrNotExist, "ref %q suffix %q", tc.oriRef, tc.suffix)
assert.Empty(t, ref, "ref %q suffix %q", tc.oriRef, tc.suffix)
}
}
func TestCompareBranchesNoCommonMergeBase(t *testing.T) {
defer tests.PrepareTestEnv(t)()