mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-15 15:42:17 +09:00
Backport #39781 by @SudhanshuMatrix ## What `parseGitDiffTreeLine` (`services/gitdiff/git_diff_tree.go`) read `BasePath`/`HeadPath` straight from `git diff-tree --raw` output without undoing git's C-quoting, unlike its sibling `parseLsTreeLine` (`modules/git/parse.go`), which already does this with `strconv.Unquote`. This fix applies the same unquoting to both paths in the diff-tree parser. ## Why With the default `core.quotePath=true`, git C-quotes **any path containing a non-ASCII byte** — not just tabs/newlines/backslashes. That means any file with a CJK, Cyrillic, accented-Latin, or emoji name gets octal-escaped, e.g.: ``` :100644 100644 b6fc4c6... 04fea06... M "\346\227\245\346\234\254\350\252\236.txt" ``` (`日本語.txt`, confirmed against real `git diff-tree --raw` output) `GetDiffTree` backs the file-tree sidebar on the commit, compare, and pull request diff pages (`routers/web/repo/commit.go`, `compare.go`, `pull.go`). Without unquoting, any such file shows its mangled, escaped name instead of the real one, and the wrong path can throw off jump-to-file and viewed-file-state behavior that key off the path. Co-authored-by: Shudhanshu Singh <sudhanshuwriterblc@gmail.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
252 lines
7.3 KiB
Go
252 lines
7.3 KiB
Go
// Copyright 2025 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package gitdiff
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"gitea.dev/modules/git"
|
|
"gitea.dev/modules/git/gitcmd"
|
|
"gitea.dev/modules/log"
|
|
"gitea.dev/modules/setting"
|
|
)
|
|
|
|
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(ctx, gitRepo, useMergeBase, baseSha, headSha)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cmd := gitcmd.NewCommand("diff-tree", "--raw", "-r", "--root").
|
|
AddOptionFormat("--find-renames=%s", setting.Git.DiffRenameSimilarityThreshold)
|
|
|
|
// HINT: GIT-DIFF-TREE-UI-CONFIG: apply the diff.orderfile explicitly
|
|
if git.GlobalConfig.DiffOrderFile != "" {
|
|
cmd.AddOptionFormat("-O%s", git.GlobalConfig.DiffOrderFile)
|
|
}
|
|
|
|
if useMergeBase {
|
|
cmd.AddArguments("--merge-base")
|
|
}
|
|
cmd.AddDynamicArguments(baseCommitID, headCommitID)
|
|
stdout, _, runErr := cmd.WithRepo(gitRepo).RunStdString(ctx)
|
|
if runErr != nil {
|
|
log.Warn("git diff-tree: %v", runErr)
|
|
return nil, runErr
|
|
}
|
|
|
|
return parseGitDiffTree(strings.NewReader(stdout))
|
|
}
|
|
|
|
func validateGitDiffTreeArguments(ctx context.Context, 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, "", "", errors.New("headSha is empty")
|
|
}
|
|
|
|
// if the head commit doesn't exist its and error
|
|
headCommit, err := gitRepo.GetCommit(ctx, 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(ctx)
|
|
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(ctx, gitRepo, 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(ctx, 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 line == "" {
|
|
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))
|
|
}
|
|
|
|
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]
|
|
}
|
|
|
|
if basePath, err = git.UnquoteDiffFilePath(basePath); err != nil {
|
|
return nil, fmt.Errorf("unparsable output for diff-tree --raw: `%s`, invalid base path: %w", line, err)
|
|
}
|
|
if headPath, err = git.UnquoteDiffFilePath(headPath); err != nil {
|
|
return nil, fmt.Errorf("unparsable output for diff-tree --raw: `%s`, invalid head path: %w", line, err)
|
|
}
|
|
|
|
return &DiffTreeRecord{
|
|
Status: status,
|
|
Score: score,
|
|
BaseMode: git.ParseEntryMode(fields[0]),
|
|
HeadMode: git.ParseEntryMode(fields[1]),
|
|
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, errors.New("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, errors.New("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
|
|
}
|