gitea源码

git_diff_tree.go 6.8KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. // Copyright 2025 The Gitea Authors. All rights reserved.
  2. // SPDX-License-Identifier: MIT
  3. package gitdiff
  4. import (
  5. "bufio"
  6. "context"
  7. "errors"
  8. "fmt"
  9. "io"
  10. "strconv"
  11. "strings"
  12. "code.gitea.io/gitea/modules/git"
  13. "code.gitea.io/gitea/modules/git/gitcmd"
  14. "code.gitea.io/gitea/modules/log"
  15. )
  16. type DiffTree struct {
  17. Files []*DiffTreeRecord
  18. }
  19. type DiffTreeRecord struct {
  20. // Status is one of 'added', 'deleted', 'modified', 'renamed', 'copied', 'typechanged', 'unmerged', 'unknown'
  21. Status string
  22. // For renames and copies, the percentage of similarity between the source and target of the move/rename.
  23. Score uint8
  24. HeadPath string
  25. BasePath string
  26. HeadMode git.EntryMode
  27. BaseMode git.EntryMode
  28. HeadBlobID string
  29. BaseBlobID string
  30. }
  31. // GetDiffTree returns the list of path of the files that have changed between the two commits.
  32. // If useMergeBase is true, the diff will be calculated using the merge base of the two commits.
  33. // This is the same behavior as using a three-dot diff in git diff.
  34. func GetDiffTree(ctx context.Context, gitRepo *git.Repository, useMergeBase bool, baseSha, headSha string) (*DiffTree, error) {
  35. gitDiffTreeRecords, err := runGitDiffTree(ctx, gitRepo, useMergeBase, baseSha, headSha)
  36. if err != nil {
  37. return nil, err
  38. }
  39. return &DiffTree{
  40. Files: gitDiffTreeRecords,
  41. }, nil
  42. }
  43. func runGitDiffTree(ctx context.Context, gitRepo *git.Repository, useMergeBase bool, baseSha, headSha string) ([]*DiffTreeRecord, error) {
  44. useMergeBase, baseCommitID, headCommitID, err := validateGitDiffTreeArguments(gitRepo, useMergeBase, baseSha, headSha)
  45. if err != nil {
  46. return nil, err
  47. }
  48. cmd := gitcmd.NewCommand("diff-tree", "--raw", "-r", "--find-renames", "--root")
  49. if useMergeBase {
  50. cmd.AddArguments("--merge-base")
  51. }
  52. cmd.AddDynamicArguments(baseCommitID, headCommitID)
  53. stdout, _, runErr := cmd.RunStdString(ctx, &gitcmd.RunOpts{Dir: gitRepo.Path})
  54. if runErr != nil {
  55. log.Warn("git diff-tree: %v", runErr)
  56. return nil, runErr
  57. }
  58. return parseGitDiffTree(strings.NewReader(stdout))
  59. }
  60. func validateGitDiffTreeArguments(gitRepo *git.Repository, useMergeBase bool, baseSha, headSha string) (shouldUseMergeBase bool, resolvedBaseSha, resolvedHeadSha string, err error) {
  61. // if the head is empty its an error
  62. if headSha == "" {
  63. return false, "", "", errors.New("headSha is empty")
  64. }
  65. // if the head commit doesn't exist its and error
  66. headCommit, err := gitRepo.GetCommit(headSha)
  67. if err != nil {
  68. return false, "", "", fmt.Errorf("failed to get commit headSha: %v", err)
  69. }
  70. headCommitID := headCommit.ID.String()
  71. // if the base is empty we should use the parent of the head commit
  72. if baseSha == "" {
  73. // if the headCommit has no parent we should use an empty commit
  74. // this can happen when we are generating a diff against an orphaned commit
  75. if headCommit.ParentCount() == 0 {
  76. objectFormat, err := gitRepo.GetObjectFormat()
  77. if err != nil {
  78. return false, "", "", err
  79. }
  80. // We set use merge base to false because we have no base commit
  81. return false, objectFormat.EmptyTree().String(), headCommitID, nil
  82. }
  83. baseCommit, err := headCommit.Parent(0)
  84. if err != nil {
  85. return false, "", "", fmt.Errorf("baseSha is '', attempted to use parent of commit %s, got error: %v", headCommit.ID.String(), err)
  86. }
  87. return useMergeBase, baseCommit.ID.String(), headCommitID, nil
  88. }
  89. // try and get the base commit
  90. baseCommit, err := gitRepo.GetCommit(baseSha)
  91. // propagate the error if we couldn't get the base commit
  92. if err != nil {
  93. return useMergeBase, "", "", fmt.Errorf("failed to get base commit %s: %v", baseSha, err)
  94. }
  95. return useMergeBase, baseCommit.ID.String(), headCommit.ID.String(), nil
  96. }
  97. func parseGitDiffTree(gitOutput io.Reader) ([]*DiffTreeRecord, error) {
  98. /*
  99. The output of `git diff-tree --raw -r --find-renames` is of the form:
  100. :<old_mode> <new_mode> <old_sha> <new_sha> <status>\t<path>
  101. or for renames:
  102. :<old_mode> <new_mode> <old_sha> <new_sha> <status>\t<old_path>\t<new_path>
  103. See: <https://git-scm.com/docs/git-diff-tree#_raw_output_format> for more details
  104. */
  105. results := make([]*DiffTreeRecord, 0)
  106. lines := bufio.NewScanner(gitOutput)
  107. for lines.Scan() {
  108. line := lines.Text()
  109. if len(line) == 0 {
  110. continue
  111. }
  112. record, err := parseGitDiffTreeLine(line)
  113. if err != nil {
  114. return nil, err
  115. }
  116. results = append(results, record)
  117. }
  118. if err := lines.Err(); err != nil {
  119. return nil, err
  120. }
  121. return results, nil
  122. }
  123. func parseGitDiffTreeLine(line string) (*DiffTreeRecord, error) {
  124. line = strings.TrimPrefix(line, ":")
  125. splitSections := strings.SplitN(line, "\t", 2)
  126. if len(splitSections) < 2 {
  127. return nil, fmt.Errorf("unparsable output for diff-tree --raw: `%s`)", line)
  128. }
  129. fields := strings.Fields(splitSections[0])
  130. if len(fields) < 5 {
  131. return nil, fmt.Errorf("unparsable output for diff-tree --raw: `%s`, expected 5 space delimited values got %d)", line, len(fields))
  132. }
  133. baseMode, err := git.ParseEntryMode(fields[0])
  134. if err != nil {
  135. return nil, err
  136. }
  137. headMode, err := git.ParseEntryMode(fields[1])
  138. if err != nil {
  139. return nil, err
  140. }
  141. baseBlobID := fields[2]
  142. headBlobID := fields[3]
  143. status, score, err := statusFromLetter(fields[4])
  144. if err != nil {
  145. return nil, fmt.Errorf("unparsable output for diff-tree --raw: %s, error: %s", line, err)
  146. }
  147. filePaths := strings.Split(splitSections[1], "\t")
  148. var headPath, basePath string
  149. if status == "renamed" {
  150. if len(filePaths) != 2 {
  151. return nil, fmt.Errorf("unparsable output for diff-tree --raw: `%s`, expected 2 paths found %d", line, len(filePaths))
  152. }
  153. basePath = filePaths[0]
  154. headPath = filePaths[1]
  155. } else {
  156. basePath = filePaths[0]
  157. headPath = filePaths[0]
  158. }
  159. return &DiffTreeRecord{
  160. Status: status,
  161. Score: score,
  162. BaseMode: baseMode,
  163. HeadMode: headMode,
  164. BaseBlobID: baseBlobID,
  165. HeadBlobID: headBlobID,
  166. BasePath: basePath,
  167. HeadPath: headPath,
  168. }, nil
  169. }
  170. func statusFromLetter(rawStatus string) (status string, score uint8, err error) {
  171. if len(rawStatus) < 1 {
  172. return "", 0, errors.New("empty status letter")
  173. }
  174. switch rawStatus[0] {
  175. case 'A':
  176. return "added", 0, nil
  177. case 'D':
  178. return "deleted", 0, nil
  179. case 'M':
  180. return "modified", 0, nil
  181. case 'R':
  182. score, err = tryParseStatusScore(rawStatus)
  183. return "renamed", score, err
  184. case 'C':
  185. score, err = tryParseStatusScore(rawStatus)
  186. return "copied", score, err
  187. case 'T':
  188. return "typechanged", 0, nil
  189. case 'U':
  190. return "unmerged", 0, nil
  191. case 'X':
  192. return "unknown", 0, nil
  193. default:
  194. return "", 0, fmt.Errorf("unknown status letter: '%s'", rawStatus)
  195. }
  196. }
  197. func tryParseStatusScore(rawStatus string) (uint8, error) {
  198. if len(rawStatus) < 2 {
  199. return 0, errors.New("status score missing")
  200. }
  201. score, err := strconv.ParseUint(rawStatus[1:], 10, 8)
  202. if err != nil {
  203. return 0, fmt.Errorf("failed to parse status score: %w", err)
  204. } else if score > 100 {
  205. return 0, fmt.Errorf("status score out of range: %d", score)
  206. }
  207. return uint8(score), nil
  208. }