You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

NativeGitBlameCommand.java 7.9KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. /*
  2. * SonarQube
  3. * Copyright (C) 2009-2024 SonarSource SA
  4. * mailto:info AT sonarsource DOT com
  5. *
  6. * This program is free software; you can redistribute it and/or
  7. * modify it under the terms of the GNU Lesser General Public
  8. * License as published by the Free Software Foundation; either
  9. * version 3 of the License, or (at your option) any later version.
  10. *
  11. * This program is distributed in the hope that it will be useful,
  12. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  14. * Lesser General Public License for more details.
  15. *
  16. * You should have received a copy of the GNU Lesser General Public License
  17. * along with this program; if not, write to the Free Software Foundation,
  18. * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  19. */
  20. package org.sonar.scm.git;
  21. import java.io.IOException;
  22. import java.nio.file.Path;
  23. import java.time.Instant;
  24. import java.util.Date;
  25. import java.util.LinkedList;
  26. import java.util.List;
  27. import java.util.regex.Matcher;
  28. import java.util.regex.Pattern;
  29. import java.util.stream.Collectors;
  30. import org.apache.commons.lang3.math.NumberUtils;
  31. import org.slf4j.Logger;
  32. import org.slf4j.LoggerFactory;
  33. import org.sonar.api.batch.scm.BlameLine;
  34. import org.sonar.api.utils.System2;
  35. import org.sonar.api.utils.Version;
  36. import org.springframework.beans.factory.annotation.Autowired;
  37. import static java.util.Collections.emptyList;
  38. import static org.sonar.api.utils.Preconditions.checkState;
  39. public class NativeGitBlameCommand {
  40. protected static final String BLAME_COMMAND = "blame";
  41. protected static final String GIT_DIR_FLAG = "--git-dir";
  42. protected static final String GIT_DIR_ARGUMENT = "%s/.git";
  43. protected static final String GIT_DIR_FORCE_FLAG = "-C";
  44. private static final Logger LOG = LoggerFactory.getLogger(NativeGitBlameCommand.class);
  45. private static final Pattern EMAIL_PATTERN = Pattern.compile("<(.*?)>");
  46. private static final String COMMITTER_TIME = "committer-time ";
  47. private static final String AUTHOR_MAIL = "author-mail ";
  48. private static final String MINIMUM_REQUIRED_GIT_VERSION = "2.24.0";
  49. private static final String DEFAULT_GIT_COMMAND = "git";
  50. private static final String BLAME_LINE_PORCELAIN_FLAG = "--line-porcelain";
  51. private static final String FILENAME_SEPARATOR_FLAG = "--";
  52. private static final String IGNORE_WHITESPACES = "-w";
  53. private static final Pattern whitespaceRegex = Pattern.compile("\\s+");
  54. private static final Pattern semanticVersionDelimiter = Pattern.compile("\\.");
  55. private final System2 system;
  56. private final ProcessWrapperFactory processWrapperFactory;
  57. private String gitCommand;
  58. @Autowired
  59. public NativeGitBlameCommand(System2 system, ProcessWrapperFactory processWrapperFactory) {
  60. this.system = system;
  61. this.processWrapperFactory = processWrapperFactory;
  62. }
  63. NativeGitBlameCommand(String gitCommand, System2 system, ProcessWrapperFactory processWrapperFactory) {
  64. this.gitCommand = gitCommand;
  65. this.system = system;
  66. this.processWrapperFactory = processWrapperFactory;
  67. }
  68. /**
  69. * This method must be executed before org.sonar.scm.git.GitBlameCommand#blame
  70. *
  71. * @return true, if native git is installed
  72. */
  73. public boolean checkIfEnabled() {
  74. try {
  75. this.gitCommand = locateDefaultGit();
  76. MutableString stdOut = new MutableString();
  77. this.processWrapperFactory.create(null, l -> stdOut.string = l, gitCommand, "--version").execute();
  78. return stdOut.string != null && stdOut.string.startsWith("git version") && isCompatibleGitVersion(stdOut.string);
  79. } catch (Exception e) {
  80. LOG.debug("Failed to find git native client", e);
  81. return false;
  82. }
  83. }
  84. private String locateDefaultGit() throws IOException {
  85. if (this.gitCommand != null) {
  86. return this.gitCommand;
  87. }
  88. // if not set fall back to defaults
  89. if (system.isOsWindows()) {
  90. return locateGitOnWindows();
  91. }
  92. return DEFAULT_GIT_COMMAND;
  93. }
  94. private String locateGitOnWindows() throws IOException {
  95. // Windows will search current directory in addition to the PATH variable, which is unsecure.
  96. // To avoid it we use where.exe to find git binary only in PATH.
  97. LOG.debug("Looking for git command in the PATH using where.exe (Windows)");
  98. List<String> whereCommandResult = new LinkedList<>();
  99. this.processWrapperFactory.create(null, whereCommandResult::add, "C:\\Windows\\System32\\where.exe", "$PATH:git.exe")
  100. .execute();
  101. if (!whereCommandResult.isEmpty()) {
  102. String out = whereCommandResult.get(0).trim();
  103. LOG.debug("Found git.exe at {}", out);
  104. return out;
  105. }
  106. throw new IllegalStateException("git.exe not found in PATH. PATH value was: " + system.property("PATH"));
  107. }
  108. public List<BlameLine> blame(Path baseDir, String fileName) throws Exception {
  109. BlameOutputProcessor outputProcessor = new BlameOutputProcessor();
  110. try {
  111. this.processWrapperFactory.create(
  112. baseDir,
  113. outputProcessor::process,
  114. gitCommand,
  115. GIT_DIR_FLAG, String.format(GIT_DIR_ARGUMENT, baseDir), GIT_DIR_FORCE_FLAG, baseDir.toString(),
  116. BLAME_COMMAND,
  117. BLAME_LINE_PORCELAIN_FLAG, IGNORE_WHITESPACES, FILENAME_SEPARATOR_FLAG, fileName)
  118. .execute();
  119. } catch (UncommittedLineException e) {
  120. LOG.debug("Unable to blame file '{}' - it has uncommitted changes", fileName);
  121. return emptyList();
  122. }
  123. return outputProcessor.getBlameLines();
  124. }
  125. private static class BlameOutputProcessor {
  126. private final List<BlameLine> blameLines = new LinkedList<>();
  127. private String sha1 = null;
  128. private String committerTime = null;
  129. private String authorMail = null;
  130. public List<BlameLine> getBlameLines() {
  131. return blameLines;
  132. }
  133. public void process(String line) {
  134. if (sha1 == null) {
  135. sha1 = line.split(" ")[0];
  136. } else if (line.startsWith("\t")) {
  137. saveEntry();
  138. } else if (line.startsWith(COMMITTER_TIME)) {
  139. committerTime = line.substring(COMMITTER_TIME.length());
  140. } else if (line.startsWith(AUTHOR_MAIL)) {
  141. Matcher matcher = EMAIL_PATTERN.matcher(line);
  142. if (matcher.find(AUTHOR_MAIL.length())) {
  143. authorMail = matcher.group(1);
  144. }
  145. if (authorMail.equals("not.committed.yet")) {
  146. throw new UncommittedLineException();
  147. }
  148. }
  149. }
  150. private void saveEntry() {
  151. checkState(authorMail != null, "Did not find an author email for an entry");
  152. checkState(committerTime != null, "Did not find a committer time for an entry");
  153. checkState(sha1 != null, "Did not find a commit sha1 for an entry");
  154. try {
  155. blameLines.add(new BlameLine()
  156. .revision(sha1)
  157. .author(authorMail)
  158. .date(Date.from(Instant.ofEpochSecond(Long.parseLong(committerTime)))));
  159. } catch (NumberFormatException e) {
  160. throw new IllegalStateException("Invalid committer time found: " + committerTime);
  161. }
  162. authorMail = null;
  163. sha1 = null;
  164. committerTime = null;
  165. }
  166. }
  167. private static boolean isCompatibleGitVersion(String gitVersionCommandOutput) {
  168. // Due to the danger of argument injection on git blame the use of `--end-of-options` flag is necessary
  169. // The flag is available only on git versions >= 2.24.0
  170. String gitVersion = whitespaceRegex
  171. .splitAsStream(gitVersionCommandOutput)
  172. .skip(2)
  173. .findFirst()
  174. .orElse("");
  175. String formattedGitVersion = formatGitSemanticVersion(gitVersion);
  176. return Version.parse(formattedGitVersion).isGreaterThanOrEqual(Version.parse(MINIMUM_REQUIRED_GIT_VERSION));
  177. }
  178. private static String formatGitSemanticVersion(String version) {
  179. return semanticVersionDelimiter
  180. .splitAsStream(version)
  181. .takeWhile(NumberUtils::isCreatable)
  182. .collect(Collectors.joining("."));
  183. }
  184. private static class MutableString {
  185. String string;
  186. }
  187. private static class UncommittedLineException extends RuntimeException {
  188. }
  189. }