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.

GitScmProvider.java 13KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. /*
  2. * SonarQube
  3. * Copyright (C) 2009-2021 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.BufferedOutputStream;
  22. import java.io.File;
  23. import java.io.IOException;
  24. import java.nio.file.Path;
  25. import java.time.Instant;
  26. import java.util.HashMap;
  27. import java.util.List;
  28. import java.util.Map;
  29. import java.util.Optional;
  30. import java.util.Set;
  31. import java.util.regex.Pattern;
  32. import java.util.stream.Collectors;
  33. import javax.annotation.CheckForNull;
  34. import org.eclipse.jgit.api.Git;
  35. import org.eclipse.jgit.api.errors.GitAPIException;
  36. import org.eclipse.jgit.diff.DiffAlgorithm;
  37. import org.eclipse.jgit.diff.DiffEntry;
  38. import org.eclipse.jgit.diff.DiffFormatter;
  39. import org.eclipse.jgit.diff.RawTextComparator;
  40. import org.eclipse.jgit.lib.Config;
  41. import org.eclipse.jgit.lib.ConfigConstants;
  42. import org.eclipse.jgit.lib.NullProgressMonitor;
  43. import org.eclipse.jgit.lib.ObjectReader;
  44. import org.eclipse.jgit.lib.Ref;
  45. import org.eclipse.jgit.lib.Repository;
  46. import org.eclipse.jgit.lib.RepositoryBuilder;
  47. import org.eclipse.jgit.revwalk.RevCommit;
  48. import org.eclipse.jgit.revwalk.RevWalk;
  49. import org.eclipse.jgit.revwalk.filter.RevFilter;
  50. import org.eclipse.jgit.treewalk.AbstractTreeIterator;
  51. import org.eclipse.jgit.treewalk.CanonicalTreeParser;
  52. import org.eclipse.jgit.treewalk.FileTreeIterator;
  53. import org.eclipse.jgit.treewalk.filter.PathFilter;
  54. import org.sonar.api.batch.scm.BlameCommand;
  55. import org.sonar.api.batch.scm.ScmProvider;
  56. import org.sonar.api.notifications.AnalysisWarnings;
  57. import org.sonar.api.utils.MessageException;
  58. import org.sonar.api.utils.System2;
  59. import org.sonar.api.utils.log.Logger;
  60. import org.sonar.api.utils.log.Loggers;
  61. public class GitScmProvider extends ScmProvider {
  62. private static final Logger LOG = Loggers.get(GitScmProvider.class);
  63. private static final String COULD_NOT_FIND_REF = "Could not find ref '%s' in refs/heads, refs/remotes, refs/remotes/upstream or refs/remotes/origin";
  64. private final JGitBlameCommand jgitBlameCommand;
  65. private final AnalysisWarnings analysisWarnings;
  66. private final GitIgnoreCommand gitIgnoreCommand;
  67. private final System2 system2;
  68. private final String documentationLink;
  69. public GitScmProvider(JGitBlameCommand jgitBlameCommand, AnalysisWarnings analysisWarnings, GitIgnoreCommand gitIgnoreCommand, System2 system2) {
  70. this.jgitBlameCommand = jgitBlameCommand;
  71. this.analysisWarnings = analysisWarnings;
  72. this.gitIgnoreCommand = gitIgnoreCommand;
  73. this.system2 = system2;
  74. this.documentationLink = "/documentation/analysis/scm-integration/";
  75. }
  76. @Override
  77. public GitIgnoreCommand ignoreCommand() {
  78. return gitIgnoreCommand;
  79. }
  80. @Override
  81. public String key() {
  82. return "git";
  83. }
  84. @Override
  85. public boolean supports(File baseDir) {
  86. RepositoryBuilder builder = new RepositoryBuilder().findGitDir(baseDir);
  87. return builder.getGitDir() != null;
  88. }
  89. @Override
  90. public BlameCommand blameCommand() {
  91. return this.jgitBlameCommand;
  92. }
  93. @CheckForNull
  94. @Override
  95. public Set<Path> branchChangedFiles(String targetBranchName, Path rootBaseDir) {
  96. try (Repository repo = buildRepo(rootBaseDir)) {
  97. Ref targetRef = resolveTargetRef(targetBranchName, repo);
  98. if (targetRef == null) {
  99. addWarningTargetNotFound(targetBranchName);
  100. return null;
  101. }
  102. if (isDiffAlgoInvalid(repo.getConfig())) {
  103. LOG.warn("The diff algorithm configured in git is not supported. "
  104. + "No information regarding changes in the branch will be collected, which can lead to unexpected results.");
  105. return null;
  106. }
  107. Optional<RevCommit> mergeBaseCommit = findMergeBase(repo, targetRef);
  108. if (!mergeBaseCommit.isPresent()) {
  109. LOG.warn("No merge base found between HEAD and " + targetRef.getName());
  110. return null;
  111. }
  112. AbstractTreeIterator mergeBaseTree = prepareTreeParser(repo, mergeBaseCommit.get());
  113. // we compare a commit with HEAD, so no point ignoring line endings (it will be whatever is committed)
  114. try (Git git = newGit(repo)) {
  115. List<DiffEntry> diffEntries = git.diff()
  116. .setShowNameAndStatusOnly(true)
  117. .setOldTree(mergeBaseTree)
  118. .setNewTree(prepareNewTree(repo))
  119. .call();
  120. return diffEntries.stream()
  121. .filter(diffEntry -> diffEntry.getChangeType() == DiffEntry.ChangeType.ADD || diffEntry.getChangeType() == DiffEntry.ChangeType.MODIFY)
  122. .map(diffEntry -> repo.getWorkTree().toPath().resolve(diffEntry.getNewPath()))
  123. .collect(Collectors.toSet());
  124. }
  125. } catch (IOException | GitAPIException e) {
  126. LOG.warn(e.getMessage(), e);
  127. }
  128. return null;
  129. }
  130. @CheckForNull
  131. @Override
  132. public Map<Path, Set<Integer>> branchChangedLines(String targetBranchName, Path projectBaseDir, Set<Path> changedFiles) {
  133. try (Repository repo = buildRepo(projectBaseDir)) {
  134. Ref targetRef = resolveTargetRef(targetBranchName, repo);
  135. if (targetRef == null) {
  136. addWarningTargetNotFound(targetBranchName);
  137. return null;
  138. }
  139. if (isDiffAlgoInvalid(repo.getConfig())) {
  140. // we already print a warning when branchChangedFiles is called
  141. return null;
  142. }
  143. // force ignore different line endings when comparing a commit with the workspace
  144. repo.getConfig().setBoolean("core", null, "autocrlf", true);
  145. Optional<RevCommit> mergeBaseCommit = findMergeBase(repo, targetRef);
  146. if (!mergeBaseCommit.isPresent()) {
  147. LOG.warn("No merge base found between HEAD and " + targetRef.getName());
  148. return null;
  149. }
  150. Map<Path, Set<Integer>> changedLines = new HashMap<>();
  151. for (Path path : changedFiles) {
  152. collectChangedLines(repo, mergeBaseCommit.get(), changedLines, path);
  153. }
  154. return changedLines;
  155. } catch (Exception e) {
  156. LOG.warn("Failed to get changed lines from git", e);
  157. }
  158. return null;
  159. }
  160. private void addWarningTargetNotFound(String targetBranchName) {
  161. analysisWarnings.addUnique(String.format(COULD_NOT_FIND_REF
  162. + ". You may see unexpected issues and changes. "
  163. + "Please make sure to fetch this ref before pull request analysis and refer to"
  164. + " <a href=\"%s\" target=\"_blank\">the documentation</a>.", targetBranchName, documentationLink));
  165. }
  166. private void collectChangedLines(Repository repo, RevCommit mergeBaseCommit, Map<Path, Set<Integer>> changedLines, Path changedFile) {
  167. ChangedLinesComputer computer = new ChangedLinesComputer();
  168. try (DiffFormatter diffFmt = new DiffFormatter(new BufferedOutputStream(computer.receiver()))) {
  169. // copied from DiffCommand so that we can use a custom DiffFormatter which ignores white spaces.
  170. diffFmt.setRepository(repo);
  171. diffFmt.setProgressMonitor(NullProgressMonitor.INSTANCE);
  172. diffFmt.setDiffComparator(RawTextComparator.WS_IGNORE_ALL);
  173. Path workTree = repo.getWorkTree().toPath();
  174. String relativePath = workTree.relativize(changedFile).toString();
  175. PathFilter pathFilter = PathFilter.create(toGitPath(relativePath));
  176. diffFmt.setPathFilter(pathFilter);
  177. AbstractTreeIterator mergeBaseTree = prepareTreeParser(repo, mergeBaseCommit);
  178. List<DiffEntry> diffEntries = diffFmt.scan(mergeBaseTree, new FileTreeIterator(repo));
  179. diffFmt.format(diffEntries);
  180. diffFmt.flush();
  181. diffEntries.stream()
  182. .filter(diffEntry -> diffEntry.getChangeType() == DiffEntry.ChangeType.ADD || diffEntry.getChangeType() == DiffEntry.ChangeType.MODIFY)
  183. .findAny()
  184. .ifPresent(diffEntry -> changedLines.put(changedFile, computer.changedLines()));
  185. } catch (Exception e) {
  186. LOG.warn("Failed to get changed lines from git for file " + changedFile, e);
  187. }
  188. }
  189. @Override
  190. @CheckForNull
  191. public Instant forkDate(String referenceBranchName, Path projectBaseDir) {
  192. return null;
  193. }
  194. private static String toGitPath(String path) {
  195. return path.replaceAll(Pattern.quote(File.separator), "/");
  196. }
  197. @CheckForNull
  198. private Ref resolveTargetRef(String targetBranchName, Repository repo) throws IOException {
  199. String localRef = "refs/heads/" + targetBranchName;
  200. String remotesRef = "refs/remotes/" + targetBranchName;
  201. String originRef = "refs/remotes/origin/" + targetBranchName;
  202. String upstreamRef = "refs/remotes/upstream/" + targetBranchName;
  203. Ref targetRef;
  204. // Because circle ci destroys the local reference to master, try to load remote ref first.
  205. // https://discuss.circleci.com/t/git-checkout-of-a-branch-destroys-local-reference-to-master/23781
  206. if (runningOnCircleCI()) {
  207. targetRef = getFirstExistingRef(repo, originRef, localRef, upstreamRef, remotesRef);
  208. } else {
  209. targetRef = getFirstExistingRef(repo, localRef, originRef, upstreamRef, remotesRef);
  210. }
  211. if (targetRef == null) {
  212. LOG.warn(String.format(COULD_NOT_FIND_REF, targetBranchName));
  213. }
  214. return targetRef;
  215. }
  216. @CheckForNull
  217. private static Ref getFirstExistingRef(Repository repo, String... refs) throws IOException {
  218. Ref targetRef = null;
  219. for (String ref : refs) {
  220. targetRef = repo.exactRef(ref);
  221. if (targetRef != null) {
  222. break;
  223. }
  224. }
  225. return targetRef;
  226. }
  227. private boolean runningOnCircleCI() {
  228. return "true".equals(system2.envVariable("CIRCLECI"));
  229. }
  230. @Override
  231. public Path relativePathFromScmRoot(Path path) {
  232. RepositoryBuilder builder = getVerifiedRepositoryBuilder(path);
  233. return builder.getGitDir().toPath().getParent().relativize(path);
  234. }
  235. @Override
  236. @CheckForNull
  237. public String revisionId(Path path) {
  238. RepositoryBuilder builder = getVerifiedRepositoryBuilder(path);
  239. try {
  240. Ref head = getHead(builder.build());
  241. if (head == null || head.getObjectId() == null) {
  242. // can happen on fresh, empty repos
  243. return null;
  244. }
  245. return head.getObjectId().getName();
  246. } catch (IOException e) {
  247. throw new IllegalStateException("I/O error while getting revision ID for path: " + path, e);
  248. }
  249. }
  250. private static boolean isDiffAlgoInvalid(Config cfg) {
  251. try {
  252. DiffAlgorithm.getAlgorithm(cfg.getEnum(
  253. ConfigConstants.CONFIG_DIFF_SECTION, null,
  254. ConfigConstants.CONFIG_KEY_ALGORITHM,
  255. DiffAlgorithm.SupportedAlgorithm.HISTOGRAM));
  256. return false;
  257. } catch (IllegalArgumentException e) {
  258. return true;
  259. }
  260. }
  261. private static AbstractTreeIterator prepareNewTree(Repository repo) throws IOException {
  262. CanonicalTreeParser treeParser = new CanonicalTreeParser();
  263. try (ObjectReader objectReader = repo.newObjectReader()) {
  264. Ref head = getHead(repo);
  265. if (head == null) {
  266. throw new IOException("HEAD reference not found");
  267. }
  268. treeParser.reset(objectReader, repo.parseCommit(head.getObjectId()).getTree());
  269. }
  270. return treeParser;
  271. }
  272. @CheckForNull
  273. private static Ref getHead(Repository repo) throws IOException {
  274. return repo.exactRef("HEAD");
  275. }
  276. private static Optional<RevCommit> findMergeBase(Repository repo, Ref targetRef) throws IOException {
  277. try (RevWalk walk = new RevWalk(repo)) {
  278. Ref head = getHead(repo);
  279. if (head == null) {
  280. throw new IOException("HEAD reference not found");
  281. }
  282. walk.markStart(walk.parseCommit(targetRef.getObjectId()));
  283. walk.markStart(walk.parseCommit(head.getObjectId()));
  284. walk.setRevFilter(RevFilter.MERGE_BASE);
  285. RevCommit next = walk.next();
  286. if (next == null) {
  287. return Optional.empty();
  288. }
  289. RevCommit base = walk.parseCommit(next);
  290. walk.dispose();
  291. LOG.info("Merge base sha1: {}", base.getName());
  292. return Optional.of(base);
  293. }
  294. }
  295. AbstractTreeIterator prepareTreeParser(Repository repo, RevCommit commit) throws IOException {
  296. CanonicalTreeParser treeParser = new CanonicalTreeParser();
  297. try (ObjectReader objectReader = repo.newObjectReader()) {
  298. treeParser.reset(objectReader, commit.getTree());
  299. }
  300. return treeParser;
  301. }
  302. Git newGit(Repository repo) {
  303. return new Git(repo);
  304. }
  305. Repository buildRepo(Path basedir) throws IOException {
  306. return getVerifiedRepositoryBuilder(basedir).build();
  307. }
  308. static RepositoryBuilder getVerifiedRepositoryBuilder(Path basedir) {
  309. RepositoryBuilder builder = new RepositoryBuilder()
  310. .findGitDir(basedir.toFile())
  311. .setMustExist(true);
  312. if (builder.getGitDir() == null) {
  313. throw MessageException.of("Not inside a Git work tree: " + basedir);
  314. }
  315. return builder;
  316. }
  317. }