Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[pull] main from go-gitea:main #757

Merged
merged 3 commits into from
Feb 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions MAINTAINERS
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,4 @@ Kemal Zebari <[email protected]> (@kemzeb)
Rowan Bohde <[email protected]> (@bohde)
hiifong <[email protected]> (@hiifong)
metiftikci <[email protected]> (@metiftikci)
Christopher Homberger <[email protected]> (@ChristopherHX)
16 changes: 3 additions & 13 deletions modules/git/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,19 +46,9 @@ func parseLsTreeLine(line []byte) (*LsTreeEntry, error) {
entry.Size = optional.Some(size)
}

switch string(entryMode) {
case "100644":
entry.EntryMode = EntryModeBlob
case "100755":
entry.EntryMode = EntryModeExec
case "120000":
entry.EntryMode = EntryModeSymlink
case "160000":
entry.EntryMode = EntryModeCommit
case "040000", "040755": // git uses 040000 for tree object, but some users may get 040755 for unknown reasons
entry.EntryMode = EntryModeTree
default:
return nil, fmt.Errorf("unknown type: %v", string(entryMode))
entry.EntryMode, err = ParseEntryMode(string(entryMode))
if err != nil || entry.EntryMode == EntryModeNoEntry {
return nil, fmt.Errorf("invalid ls-tree output (invalid mode): %q, err: %w", line, err)
}

entry.ID, err = NewIDFromString(string(entryObjectID))
Expand Down
27 changes: 26 additions & 1 deletion modules/git/tree_entry_mode.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,20 @@

package git

import "strconv"
import (
"fmt"
"strconv"
)

// EntryMode the type of the object in the git tree
type EntryMode int

// There are only a few file modes in Git. They look like unix file modes, but they can only be
// one of these.
const (
// EntryModeNoEntry is possible if the file was added or removed in a commit. In the case of
// added the base commit will not have the file in its tree so a mode of 0o000000 is used.
EntryModeNoEntry EntryMode = 0o000000
// EntryModeBlob
EntryModeBlob EntryMode = 0o100644
// EntryModeExec
Expand All @@ -33,3 +39,22 @@ func ToEntryMode(value string) EntryMode {
v, _ := strconv.ParseInt(value, 8, 32)
return EntryMode(v)
}

func ParseEntryMode(mode string) (EntryMode, error) {
switch mode {
case "000000":
return EntryModeNoEntry, nil
case "100644":
return EntryModeBlob, nil
case "100755":
return EntryModeExec, nil
case "120000":
return EntryModeSymlink, nil
case "160000":
return EntryModeCommit, nil
case "040000", "040755": // git uses 040000 for tree object, but some users may get 040755 for unknown reasons
return EntryModeTree, nil
default:
return 0, fmt.Errorf("unparsable entry mode: %s", mode)
}
}
2 changes: 2 additions & 0 deletions options/locale/locale_ga-IE.ini
Original file line number Diff line number Diff line change
Expand Up @@ -2330,6 +2330,8 @@ settings.event_fork=Forc
settings.event_fork_desc=Forcadh stóras.
settings.event_wiki=Vicí
settings.event_wiki_desc=Leathanach Vicí cruthaithe, athainmnithe, curtha in eagar nó scriosta.
settings.event_statuses=Stádais
settings.event_statuses_desc=Nuashonraíodh Stádas Commit ón API.
settings.event_release=Scaoileadh
settings.event_release_desc=Scaoileadh foilsithe, nuashonraithe nó scriosta i stóras.
settings.event_push=Brúigh
Expand Down
249 changes: 249 additions & 0 deletions services/gitdiff/git_diff_tree.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package gitdiff

import (
"bufio"
"context"
"fmt"
"io"
"strconv"
"strings"

"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/log"
)

type DiffTree struct {
Files []*DiffTreeRecord
}

type DiffTreeRecord struct {
// Status is one of 'added', 'deleted', 'modified', 'renamed', 'copied', 'typechanged', 'unmerged', 'unknown'
Status string

// For renames and copies, the percentage of similarity between the source and target of the move/rename.
Score uint8

HeadPath string
BasePath string
HeadMode git.EntryMode
BaseMode git.EntryMode
HeadBlobID string
BaseBlobID string
}

// GetDiffTree returns the list of path of the files that have changed between the two commits.
// If useMergeBase is true, the diff will be calculated using the merge base of the two commits.
// This is the same behavior as using a three-dot diff in git diff.
func GetDiffTree(ctx context.Context, gitRepo *git.Repository, useMergeBase bool, baseSha, headSha string) (*DiffTree, error) {
gitDiffTreeRecords, err := runGitDiffTree(ctx, gitRepo, useMergeBase, baseSha, headSha)
if err != nil {
return nil, err
}

return &DiffTree{
Files: gitDiffTreeRecords,
}, nil
}

func runGitDiffTree(ctx context.Context, gitRepo *git.Repository, useMergeBase bool, baseSha, headSha string) ([]*DiffTreeRecord, error) {
useMergeBase, baseCommitID, headCommitID, err := validateGitDiffTreeArguments(gitRepo, useMergeBase, baseSha, headSha)
if err != nil {
return nil, err
}

cmd := git.NewCommand(ctx, "diff-tree", "--raw", "-r", "--find-renames", "--root")
if useMergeBase {
cmd.AddArguments("--merge-base")
}
cmd.AddDynamicArguments(baseCommitID, headCommitID)
stdout, _, runErr := cmd.RunStdString(&git.RunOpts{Dir: gitRepo.Path})
if runErr != nil {
log.Warn("git diff-tree: %v", runErr)
return nil, runErr
}

return parseGitDiffTree(strings.NewReader(stdout))
}

func validateGitDiffTreeArguments(gitRepo *git.Repository, useMergeBase bool, baseSha, headSha string) (shouldUseMergeBase bool, resolvedBaseSha, resolvedHeadSha string, err error) {
// if the head is empty its an error
if headSha == "" {
return false, "", "", fmt.Errorf("headSha is empty")
}

// if the head commit doesn't exist its and error
headCommit, err := gitRepo.GetCommit(headSha)
if err != nil {
return false, "", "", fmt.Errorf("failed to get commit headSha: %v", err)
}
headCommitID := headCommit.ID.String()

// if the base is empty we should use the parent of the head commit
if baseSha == "" {
// if the headCommit has no parent we should use an empty commit
// this can happen when we are generating a diff against an orphaned commit
if headCommit.ParentCount() == 0 {
objectFormat, err := gitRepo.GetObjectFormat()
if err != nil {
return false, "", "", err
}

// We set use merge base to false because we have no base commit
return false, objectFormat.EmptyTree().String(), headCommitID, nil
}

baseCommit, err := headCommit.Parent(0)
if err != nil {
return false, "", "", fmt.Errorf("baseSha is '', attempted to use parent of commit %s, got error: %v", headCommit.ID.String(), err)
}
return useMergeBase, baseCommit.ID.String(), headCommitID, nil
}

// try and get the base commit
baseCommit, err := gitRepo.GetCommit(baseSha)
// propagate the error if we couldn't get the base commit
if err != nil {
return useMergeBase, "", "", fmt.Errorf("failed to get base commit %s: %v", baseSha, err)
}

return useMergeBase, baseCommit.ID.String(), headCommit.ID.String(), nil
}

func parseGitDiffTree(gitOutput io.Reader) ([]*DiffTreeRecord, error) {
/*
The output of `git diff-tree --raw -r --find-renames` is of the form:

:<old_mode> <new_mode> <old_sha> <new_sha> <status>\t<path>

or for renames:

:<old_mode> <new_mode> <old_sha> <new_sha> <status>\t<old_path>\t<new_path>

See: <https://git-scm.com/docs/git-diff-tree#_raw_output_format> for more details
*/
results := make([]*DiffTreeRecord, 0)

lines := bufio.NewScanner(gitOutput)
for lines.Scan() {
line := lines.Text()

if len(line) == 0 {
continue
}

record, err := parseGitDiffTreeLine(line)
if err != nil {
return nil, err
}

results = append(results, record)
}

if err := lines.Err(); err != nil {
return nil, err
}

return results, nil
}

func parseGitDiffTreeLine(line string) (*DiffTreeRecord, error) {
line = strings.TrimPrefix(line, ":")
splitSections := strings.SplitN(line, "\t", 2)
if len(splitSections) < 2 {
return nil, fmt.Errorf("unparsable output for diff-tree --raw: `%s`)", line)
}

fields := strings.Fields(splitSections[0])
if len(fields) < 5 {
return nil, fmt.Errorf("unparsable output for diff-tree --raw: `%s`, expected 5 space delimited values got %d)", line, len(fields))
}

baseMode, err := git.ParseEntryMode(fields[0])
if err != nil {
return nil, err
}

headMode, err := git.ParseEntryMode(fields[1])
if err != nil {
return nil, err
}

baseBlobID := fields[2]
headBlobID := fields[3]

status, score, err := statusFromLetter(fields[4])
if err != nil {
return nil, fmt.Errorf("unparsable output for diff-tree --raw: %s, error: %s", line, err)
}

filePaths := strings.Split(splitSections[1], "\t")

var headPath, basePath string
if status == "renamed" {
if len(filePaths) != 2 {
return nil, fmt.Errorf("unparsable output for diff-tree --raw: `%s`, expected 2 paths found %d", line, len(filePaths))
}
basePath = filePaths[0]
headPath = filePaths[1]
} else {
basePath = filePaths[0]
headPath = filePaths[0]
}

return &DiffTreeRecord{
Status: status,
Score: score,
BaseMode: baseMode,
HeadMode: headMode,
BaseBlobID: baseBlobID,
HeadBlobID: headBlobID,
BasePath: basePath,
HeadPath: headPath,
}, nil
}

func statusFromLetter(rawStatus string) (status string, score uint8, err error) {
if len(rawStatus) < 1 {
return "", 0, fmt.Errorf("empty status letter")
}
switch rawStatus[0] {
case 'A':
return "added", 0, nil
case 'D':
return "deleted", 0, nil
case 'M':
return "modified", 0, nil
case 'R':
score, err = tryParseStatusScore(rawStatus)
return "renamed", score, err
case 'C':
score, err = tryParseStatusScore(rawStatus)
return "copied", score, err
case 'T':
return "typechanged", 0, nil
case 'U':
return "unmerged", 0, nil
case 'X':
return "unknown", 0, nil
default:
return "", 0, fmt.Errorf("unknown status letter: '%s'", rawStatus)
}
}

func tryParseStatusScore(rawStatus string) (uint8, error) {
if len(rawStatus) < 2 {
return 0, fmt.Errorf("status score missing")
}

score, err := strconv.ParseUint(rawStatus[1:], 10, 8)
if err != nil {
return 0, fmt.Errorf("failed to parse status score: %w", err)
} else if score > 100 {
return 0, fmt.Errorf("status score out of range: %d", score)
}

return uint8(score), nil
}
Loading
Loading