mirror of
https://github.com/hanzoai/forge.git
synced 2026-10-30 15:00:39 +00:00
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in NOTICE, alongside LICENSE. The header lint accepts any copyright line above the SPDX line, so it no longer demands an upstream one. Co-authored-by: Hanzo Dev <[email protected]>
222 lines
7.6 KiB
Go
222 lines
7.6 KiB
Go
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package common
|
|
|
|
import (
|
|
"context"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
|
|
repo_model "github.com/hanzoai/git/models/repo"
|
|
user_model "github.com/hanzoai/git/models/user"
|
|
"github.com/hanzoai/git/modules/git"
|
|
"github.com/hanzoai/git/modules/util"
|
|
)
|
|
|
|
type CompareRouterReq struct {
|
|
BaseOriRef string
|
|
BaseOriRefSuffix string
|
|
|
|
CompareSeparator string
|
|
|
|
HeadOwner string
|
|
HeadRepoName string
|
|
HeadOriRef string
|
|
HeadOriRefSuffix string
|
|
}
|
|
|
|
func (cr *CompareRouterReq) DirectComparison() bool {
|
|
// FIXME: the design of "DirectComparison" is wrong, it loses the information of `^`
|
|
// To correctly handle the comparison, developers should use `ci.CompareSeparator` directly, all "DirectComparison" related code should be rewritten.
|
|
return cr.CompareSeparator == ".."
|
|
}
|
|
|
|
func parseHead(head string) (headOwnerName, headRepoName, headRef string) {
|
|
paths := strings.SplitN(head, ":", 2)
|
|
if len(paths) == 1 {
|
|
return "", "", paths[0]
|
|
}
|
|
ownerRepo := strings.SplitN(paths[0], "/", 2)
|
|
if len(ownerRepo) == 1 {
|
|
return paths[0], "", paths[1]
|
|
}
|
|
return ownerRepo[0], ownerRepo[1], paths[1]
|
|
}
|
|
|
|
// ParseCompareRouterParam Get compare information from the router parameter.
|
|
// A full compare url is of the form:
|
|
//
|
|
// 0. /{:baseOwner}/{:baseRepoName}/compare
|
|
// 1. /{:baseOwner}/{:baseRepoName}/compare/{:baseBranch}...{:headBranch}
|
|
// 2. /{:baseOwner}/{:baseRepoName}/compare/{:baseBranch}...{:headOwner}:{:headBranch}
|
|
// 3. /{:baseOwner}/{:baseRepoName}/compare/{:baseBranch}...{:headOwner}/{:headRepoName}:{:headBranch}
|
|
// 4. /{:baseOwner}/{:baseRepoName}/compare/{:headBranch}
|
|
// 5. /{:baseOwner}/{:baseRepoName}/compare/{:headOwner}:{:headBranch}
|
|
// 6. /{:baseOwner}/{:baseRepoName}/compare/{:headOwner}/{:headRepoName}:{:headBranch}
|
|
//
|
|
// Here we obtain the infoPath "{:baseBranch}...[{:headOwner}/{:headRepoName}:]{:headBranch}" as ctx.PathParam("*")
|
|
// with the :baseRepo in ctx.Repo.
|
|
//
|
|
// Note: Generally :headRepoName is not provided here - we are only passed :headOwner.
|
|
//
|
|
// How do we determine the :headRepo?
|
|
//
|
|
// 1. If :headOwner is not set then the :headRepo = :baseRepo
|
|
// 2. If :headOwner is set - then look for the fork of :baseRepo owned by :headOwner
|
|
// 3. But... :baseRepo could be a fork of :headOwner's repo - so check that
|
|
// 4. Now, :baseRepo and :headRepos could be forks of the same repo - so check that
|
|
//
|
|
// format: <base branch>...[<head repo>:]<head branch>
|
|
// base<-head: master...head:feature
|
|
// same repo: master...feature
|
|
func ParseCompareRouterParam(routerParam string) *CompareRouterReq {
|
|
if routerParam == "" {
|
|
return &CompareRouterReq{}
|
|
}
|
|
|
|
sep := "..."
|
|
basePart, headPart, ok := strings.Cut(routerParam, sep)
|
|
if !ok {
|
|
sep = ".."
|
|
basePart, headPart, ok = strings.Cut(routerParam, sep)
|
|
if !ok {
|
|
headOwnerName, headRepoName, headOriRef := parseHead(routerParam)
|
|
headOriRef, headOriRefSuffix := git.ParseRefSuffix(headOriRef)
|
|
return &CompareRouterReq{
|
|
HeadOriRef: headOriRef,
|
|
HeadOriRefSuffix: headOriRefSuffix,
|
|
HeadOwner: headOwnerName,
|
|
HeadRepoName: headRepoName,
|
|
CompareSeparator: "...",
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
// FindHeadRepo tries to find the head repository based on the base repository and head user ID.
|
|
func FindHeadRepo(ctx context.Context, baseRepo *repo_model.Repository, headUserID int64) (*repo_model.Repository, error) {
|
|
if baseRepo.IsFork {
|
|
curRepo := baseRepo
|
|
for curRepo.OwnerID != headUserID { // We assume the fork deepth is not too deep.
|
|
if err := curRepo.GetBaseRepo(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if curRepo.BaseRepo == nil {
|
|
return findHeadRepoFromRootBase(ctx, curRepo, headUserID, maxForkTraverseLevel)
|
|
}
|
|
curRepo = curRepo.BaseRepo
|
|
}
|
|
return curRepo, nil
|
|
}
|
|
|
|
return findHeadRepoFromRootBase(ctx, baseRepo, headUserID, maxForkTraverseLevel)
|
|
}
|
|
|
|
func GetHeadOwnerAndRepo(ctx context.Context, baseRepo *repo_model.Repository, compareReq *CompareRouterReq) (headOwner *user_model.User, headRepo *repo_model.Repository, err error) {
|
|
if compareReq.HeadOwner == "" {
|
|
if compareReq.HeadRepoName != "" { // unsupported syntax
|
|
return nil, nil, util.ErrorWrap(util.ErrInvalidArgument, "head owner must be specified when head repo name is given")
|
|
}
|
|
|
|
return baseRepo.Owner, baseRepo, nil
|
|
}
|
|
|
|
if compareReq.HeadOwner == baseRepo.Owner.Name {
|
|
headOwner = baseRepo.Owner
|
|
} else {
|
|
headOwner, err = user_model.GetUserByName(ctx, compareReq.HeadOwner)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
if compareReq.HeadRepoName == "" {
|
|
if headOwner.ID == baseRepo.OwnerID {
|
|
headRepo = baseRepo
|
|
} else {
|
|
headRepo, err = FindHeadRepo(ctx, baseRepo, headOwner.ID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if headRepo == nil {
|
|
return nil, nil, util.ErrorWrap(util.ErrInvalidArgument, "the user %s does not have a fork of the base repository", headOwner.Name)
|
|
}
|
|
}
|
|
} else {
|
|
if compareReq.HeadOwner == baseRepo.Owner.Name && compareReq.HeadRepoName == baseRepo.Name {
|
|
headRepo = baseRepo
|
|
} else {
|
|
headRepo, err = repo_model.GetRepositoryByName(ctx, headOwner.ID, compareReq.HeadRepoName)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
}
|
|
return headOwner, headRepo, nil
|
|
}
|
|
|
|
func findHeadRepoFromRootBase(ctx context.Context, baseRepo *repo_model.Repository, headUserID int64, traverseLevel int) (*repo_model.Repository, error) {
|
|
if traverseLevel == 0 {
|
|
return nil, nil //nolint:nilnil // return nil to indicate that the object does not exist
|
|
}
|
|
// test if we are lucky
|
|
repo, err := repo_model.GetUserFork(ctx, baseRepo.ID, headUserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if repo != nil {
|
|
return repo, nil
|
|
}
|
|
|
|
firstLevelForkedRepos, err := repo_model.GetRepositoriesByForkID(ctx, baseRepo.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, repo := range firstLevelForkedRepos {
|
|
forked, err := findHeadRepoFromRootBase(ctx, repo, headUserID, traverseLevel-1)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if forked != nil {
|
|
return forked, nil
|
|
}
|
|
}
|
|
return nil, nil //nolint:nilnil // return nil to indicate that the object does not exist
|
|
}
|