446 lines
13 KiB
Go
446 lines
13 KiB
Go
// Copyright 2023 Harness, Inc.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package gitea
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/harness/gitness/gitrpc/internal/types"
|
|
|
|
gitea "code.gitea.io/gitea/modules/git"
|
|
)
|
|
|
|
const (
|
|
giteaPrettyLogFormat = `--pretty=format:%H`
|
|
)
|
|
|
|
// GetLatestCommit gets the latest commit of a path relative from the provided reference.
|
|
// Note: ref can be Branch / Tag / CommitSHA.
|
|
func (g Adapter) GetLatestCommit(ctx context.Context, repoPath string,
|
|
ref string, treePath string) (*types.Commit, error) {
|
|
treePath = cleanTreePath(treePath)
|
|
|
|
giteaRepo, err := gitea.OpenRepository(ctx, repoPath)
|
|
if err != nil {
|
|
return nil, processGiteaErrorf(err, "failed to open repository")
|
|
}
|
|
defer giteaRepo.Close()
|
|
|
|
giteaCommit, err := giteaGetCommitByPath(giteaRepo, ref, treePath)
|
|
if err != nil {
|
|
return nil, processGiteaErrorf(err, "error getting latest commit for '%s'", treePath)
|
|
}
|
|
|
|
return mapGiteaCommit(giteaCommit)
|
|
}
|
|
|
|
// giteaGetCommitByPath returns the latest commit per specific branch.
|
|
func giteaGetCommitByPath(giteaRepo *gitea.Repository, ref string, treePath string) (*gitea.Commit, error) {
|
|
if treePath == "" {
|
|
treePath = "."
|
|
}
|
|
|
|
// NOTE: the difference to gitea implementation is passing `ref`.
|
|
stdout, _, runErr := gitea.NewCommand(giteaRepo.Ctx, "log", ref, "-1", giteaPrettyLogFormat, "--", treePath).
|
|
RunStdBytes(&gitea.RunOpts{Dir: giteaRepo.Path})
|
|
if runErr != nil {
|
|
return nil, fmt.Errorf("failed to trigger log command: %w", runErr)
|
|
}
|
|
|
|
lines := parseLinesToSlice(stdout)
|
|
|
|
giteaCommits, err := getGiteaCommits(giteaRepo, lines)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return giteaCommits[0], nil
|
|
}
|
|
|
|
func getGiteaCommits(giteaRepo *gitea.Repository, commitIDs []string) ([]*gitea.Commit, error) {
|
|
var giteaCommits []*gitea.Commit
|
|
if len(commitIDs) == 0 {
|
|
return giteaCommits, nil
|
|
}
|
|
|
|
for _, commitID := range commitIDs {
|
|
commit, err := giteaRepo.GetCommit(commitID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get commit '%s': %w", commitID, err)
|
|
}
|
|
giteaCommits = append(giteaCommits, commit)
|
|
}
|
|
|
|
return giteaCommits, nil
|
|
}
|
|
|
|
func (g Adapter) listCommitSHAs(
|
|
giteaRepo *gitea.Repository,
|
|
ref string,
|
|
page int,
|
|
limit int,
|
|
filter types.CommitFilter,
|
|
) ([]string, error) {
|
|
args := make([]string, 0, 16)
|
|
args = append(args, "rev-list")
|
|
|
|
// return commits only up to a certain reference if requested
|
|
if filter.AfterRef != "" {
|
|
// ^REF tells the rev-list command to return only commits that aren't reachable by SHA
|
|
args = append(args, fmt.Sprintf("^%s", filter.AfterRef))
|
|
}
|
|
// add refCommitSHA as starting point
|
|
args = append(args, ref)
|
|
|
|
if len(filter.Path) != 0 {
|
|
args = append(args, "--", filter.Path)
|
|
}
|
|
|
|
// add pagination if requested
|
|
// TODO: we should add absolut limits to protect gitrpc (return error)
|
|
if limit > 0 {
|
|
args = append(args, "--max-count", fmt.Sprint(limit))
|
|
|
|
if page > 1 {
|
|
args = append(args, "--skip", fmt.Sprint((page-1)*limit))
|
|
}
|
|
}
|
|
|
|
if filter.Since > 0 || filter.Until > 0 {
|
|
args = append(args, "--date", "unix")
|
|
}
|
|
if filter.Since > 0 {
|
|
args = append(args, "--since", strconv.FormatInt(filter.Since, 10))
|
|
}
|
|
if filter.Until > 0 {
|
|
args = append(args, "--until", strconv.FormatInt(filter.Until, 10))
|
|
}
|
|
if filter.Committer != "" {
|
|
args = append(args, "--committer", filter.Committer)
|
|
}
|
|
|
|
stdout, _, runErr := gitea.NewCommand(giteaRepo.Ctx, args...).RunStdBytes(&gitea.RunOpts{Dir: giteaRepo.Path})
|
|
if runErr != nil {
|
|
// TODO: handle error in case they don't have a common merge base!
|
|
return nil, processGiteaErrorf(runErr, "failed to trigger rev-list command")
|
|
}
|
|
|
|
return parseLinesToSlice(stdout), nil
|
|
}
|
|
|
|
// ListCommitSHAs lists the commits reachable from ref.
|
|
// Note: ref & afterRef can be Branch / Tag / CommitSHA.
|
|
// Note: commits returned are [ref->...->afterRef).
|
|
func (g Adapter) ListCommitSHAs(
|
|
ctx context.Context,
|
|
repoPath string,
|
|
ref string,
|
|
page int,
|
|
limit int,
|
|
filter types.CommitFilter,
|
|
) ([]string, error) {
|
|
giteaRepo, err := gitea.OpenRepository(ctx, repoPath)
|
|
if err != nil {
|
|
return nil, processGiteaErrorf(err, "failed to open repository")
|
|
}
|
|
defer giteaRepo.Close()
|
|
|
|
return g.listCommitSHAs(giteaRepo, ref, page, limit, filter)
|
|
}
|
|
|
|
// ListCommits lists the commits reachable from ref.
|
|
// Note: ref & afterRef can be Branch / Tag / CommitSHA.
|
|
// Note: commits returned are [ref->...->afterRef).
|
|
func (g Adapter) ListCommits(ctx context.Context,
|
|
repoPath string,
|
|
ref string,
|
|
page int, limit int, filter types.CommitFilter,
|
|
) ([]types.Commit, []types.PathRenameDetails, error) {
|
|
giteaRepo, err := gitea.OpenRepository(ctx, repoPath)
|
|
if err != nil {
|
|
return nil, nil, processGiteaErrorf(err, "failed to open repository")
|
|
}
|
|
defer giteaRepo.Close()
|
|
|
|
commitSHAs, err := g.listCommitSHAs(giteaRepo, ref, page, limit, filter)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
giteaCommits, err := getGiteaCommits(giteaRepo, commitSHAs)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
commits := make([]types.Commit, len(giteaCommits))
|
|
for i := range giteaCommits {
|
|
var commit *types.Commit
|
|
commit, err = mapGiteaCommit(giteaCommits[i])
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
commits[i] = *commit
|
|
}
|
|
|
|
if len(filter.Path) != 0 {
|
|
renameDetailsList, err := getRenameDetails(giteaRepo, commits, filter.Path)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
cleanedUpCommits := cleanupCommitsForRename(commits, renameDetailsList, filter.Path)
|
|
return cleanedUpCommits, renameDetailsList, nil
|
|
}
|
|
|
|
return commits, nil, nil
|
|
}
|
|
|
|
// In case of rename of a file, same commit will be listed twice - Once in old file and second time in new file.
|
|
// Hence, we are making it a pattern to only list it as part of new file and not as part of old file.
|
|
func cleanupCommitsForRename(
|
|
commits []types.Commit,
|
|
renameDetails []types.PathRenameDetails,
|
|
path string,
|
|
) []types.Commit {
|
|
if len(commits) == 0 {
|
|
return commits
|
|
}
|
|
for _, renameDetail := range renameDetails {
|
|
// Since rename details is present it implies that we have commits and hence don't need null check.
|
|
if commits[0].SHA == renameDetail.CommitSHABefore && path == renameDetail.OldPath {
|
|
return commits[1:]
|
|
}
|
|
}
|
|
return commits
|
|
}
|
|
|
|
func getRenameDetails(
|
|
giteaRepo *gitea.Repository,
|
|
commits []types.Commit,
|
|
path string) ([]types.PathRenameDetails, error) {
|
|
if len(commits) == 0 {
|
|
return []types.PathRenameDetails{}, nil
|
|
}
|
|
|
|
renameDetailsList := make([]types.PathRenameDetails, 0, 2)
|
|
|
|
renameDetails, err := giteaGetRenameDetails(giteaRepo, commits[0].SHA, path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if renameDetails.NewPath != "" || renameDetails.OldPath != "" {
|
|
renameDetails.CommitSHABefore = commits[0].SHA
|
|
renameDetailsList = append(renameDetailsList, *renameDetails)
|
|
}
|
|
|
|
if len(commits) == 1 {
|
|
return renameDetailsList, nil
|
|
}
|
|
|
|
renameDetailsLast, err := giteaGetRenameDetails(giteaRepo, commits[len(commits)-1].SHA, path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if renameDetailsLast.NewPath != "" || renameDetailsLast.OldPath != "" {
|
|
renameDetailsLast.CommitSHAAfter = commits[len(commits)-1].SHA
|
|
renameDetailsList = append(renameDetailsList, *renameDetailsLast)
|
|
}
|
|
return renameDetailsList, nil
|
|
}
|
|
|
|
func giteaGetRenameDetails(giteaRepo *gitea.Repository, ref string, path string) (*types.PathRenameDetails, error) {
|
|
stdout, _, runErr := gitea.NewCommand(giteaRepo.Ctx, "log", ref, "--name-status", "--pretty=format:", "-1").
|
|
RunStdBytes(&gitea.RunOpts{Dir: giteaRepo.Path})
|
|
if runErr != nil {
|
|
return nil, fmt.Errorf("failed to trigger log command: %w", runErr)
|
|
}
|
|
|
|
lines := parseLinesToSlice(stdout)
|
|
|
|
changeType, oldPath, newPath, err := getFileChangeTypeFromLog(lines, path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if strings.HasPrefix(*changeType, "R") {
|
|
return &types.PathRenameDetails{
|
|
OldPath: *oldPath,
|
|
NewPath: *newPath,
|
|
}, nil
|
|
}
|
|
|
|
return &types.PathRenameDetails{}, nil
|
|
}
|
|
|
|
func getFileChangeTypeFromLog(changeStrings []string, filePath string) (*string, *string, *string, error) {
|
|
for _, changeString := range changeStrings {
|
|
if strings.Contains(changeString, filePath) {
|
|
changeInfo := strings.Split(changeString, "\t")
|
|
if len(changeInfo) != 3 {
|
|
return &changeInfo[0], nil, nil, nil
|
|
}
|
|
return &changeInfo[0], &changeInfo[1], &changeInfo[2], nil
|
|
}
|
|
}
|
|
return nil, nil, nil, fmt.Errorf("could not parse change for the file")
|
|
}
|
|
|
|
// GetCommit returns the (latest) commit for a specific ref.
|
|
// Note: ref can be Branch / Tag / CommitSHA.
|
|
func (g Adapter) GetCommit(ctx context.Context, repoPath string, ref string) (*types.Commit, error) {
|
|
giteaRepo, err := gitea.OpenRepository(ctx, repoPath)
|
|
if err != nil {
|
|
return nil, processGiteaErrorf(err, "failed to open repository")
|
|
}
|
|
defer giteaRepo.Close()
|
|
|
|
commit, err := giteaRepo.GetCommit(ref)
|
|
if err != nil {
|
|
return nil, processGiteaErrorf(err, "error getting commit for ref '%s'", ref)
|
|
}
|
|
|
|
return mapGiteaCommit(commit)
|
|
}
|
|
|
|
func (g Adapter) GetFullCommitID(ctx context.Context, repoPath, shortID string) (string, error) {
|
|
return gitea.GetFullCommitID(ctx, repoPath, shortID)
|
|
}
|
|
|
|
// GetCommits returns the (latest) commits for a specific list of refs.
|
|
// Note: ref can be Branch / Tag / CommitSHA.
|
|
func (g Adapter) GetCommits(ctx context.Context, repoPath string, refs []string) ([]types.Commit, error) {
|
|
giteaRepo, err := gitea.OpenRepository(ctx, repoPath)
|
|
if err != nil {
|
|
return nil, processGiteaErrorf(err, "failed to open repository")
|
|
}
|
|
defer giteaRepo.Close()
|
|
|
|
commits := make([]types.Commit, len(refs))
|
|
for i, sha := range refs {
|
|
var giteaCommit *gitea.Commit
|
|
giteaCommit, err = giteaRepo.GetCommit(sha)
|
|
if err != nil {
|
|
return nil, processGiteaErrorf(err, "error getting commit '%s'", sha)
|
|
}
|
|
|
|
var commit *types.Commit
|
|
commit, err = mapGiteaCommit(giteaCommit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
commits[i] = *commit
|
|
}
|
|
|
|
return commits, nil
|
|
}
|
|
|
|
// GetCommitDivergences returns the count of the diverging commits for all branch pairs.
|
|
// IMPORTANT: If a max is provided it limits the overal count of diverging commits
|
|
// (max 10 could lead to (0, 10) while it's actually (2, 12)).
|
|
func (g Adapter) GetCommitDivergences(ctx context.Context, repoPath string,
|
|
requests []types.CommitDivergenceRequest, max int32) ([]types.CommitDivergence, error) {
|
|
var err error
|
|
res := make([]types.CommitDivergence, len(requests))
|
|
for i, req := range requests {
|
|
res[i], err = g.getCommitDivergence(ctx, repoPath, req, max)
|
|
if errors.Is(err, types.ErrNotFound) {
|
|
res[i] = types.CommitDivergence{Ahead: -1, Behind: -1}
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return res, nil
|
|
}
|
|
|
|
// getCommitDivergence returns the count of diverging commits for a pair of branches.
|
|
// IMPORTANT: If a max is provided it limits the overal count of diverging commits
|
|
// (max 10 could lead to (0, 10) while it's actually (2, 12)).
|
|
// NOTE: Gitea implementation makes two git cli calls, but it can be done with one
|
|
// (downside is the max behavior explained above).
|
|
func (g Adapter) getCommitDivergence(ctx context.Context, repoPath string,
|
|
req types.CommitDivergenceRequest, max int32) (types.CommitDivergence, error) {
|
|
// prepare args
|
|
args := []string{
|
|
"rev-list",
|
|
"--count",
|
|
"--left-right",
|
|
}
|
|
// limit count if requested.
|
|
if max > 0 {
|
|
args = append(args, "--max-count")
|
|
args = append(args, fmt.Sprint(max))
|
|
}
|
|
// add query to get commits without shared base commits
|
|
args = append(args, fmt.Sprintf("%s...%s", req.From, req.To))
|
|
|
|
var err error
|
|
cmd := gitea.NewCommand(ctx, args...)
|
|
stdOut, stdErr, err := cmd.RunStdString(&gitea.RunOpts{Dir: repoPath})
|
|
if err != nil {
|
|
return types.CommitDivergence{},
|
|
processGiteaErrorf(err, "git rev-list failed for '%s...%s' (stdErr: '%s')", req.From, req.To, stdErr)
|
|
}
|
|
|
|
// parse output, e.g.: `1 2\n`
|
|
rawLeft, rawRight, ok := strings.Cut(stdOut, "\t")
|
|
if !ok {
|
|
return types.CommitDivergence{}, fmt.Errorf("git rev-list returned unexpected output '%s'", stdOut)
|
|
}
|
|
|
|
// trim any unnecessary characters
|
|
rawLeft = strings.TrimRight(rawLeft, " \t")
|
|
rawRight = strings.TrimRight(rawRight, " \t\n")
|
|
|
|
// parse numbers
|
|
left, err := strconv.ParseInt(rawLeft, 10, 32)
|
|
if err != nil {
|
|
return types.CommitDivergence{},
|
|
fmt.Errorf("failed to parse git rev-list output for ahead '%s' (full: '%s')): %w", rawLeft, stdOut, err)
|
|
}
|
|
right, err := strconv.ParseInt(rawRight, 10, 32)
|
|
if err != nil {
|
|
return types.CommitDivergence{},
|
|
fmt.Errorf("failed to parse git rev-list output for behind '%s' (full: '%s')): %w", rawRight, stdOut, err)
|
|
}
|
|
|
|
return types.CommitDivergence{
|
|
Ahead: int32(left),
|
|
Behind: int32(right),
|
|
}, nil
|
|
}
|
|
|
|
func parseLinesToSlice(output []byte) []string {
|
|
if len(output) == 0 {
|
|
return nil
|
|
}
|
|
|
|
lines := bytes.Split(bytes.TrimSpace(output), []byte{'\n'})
|
|
|
|
slice := make([]string, len(lines))
|
|
for i, line := range lines {
|
|
slice[i] = string(line)
|
|
}
|
|
|
|
return slice
|
|
}
|