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 14KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  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. public GitScmProvider(JGitBlameCommand jgitBlameCommand, AnalysisWarnings analysisWarnings, GitIgnoreCommand gitIgnoreCommand, System2 system2) {
  69. this.jgitBlameCommand = jgitBlameCommand;
  70. this.analysisWarnings = analysisWarnings;
  71. this.gitIgnoreCommand = gitIgnoreCommand;
  72. this.system2 = system2;
  73. }
  74. @Override
  75. public GitIgnoreCommand ignoreCommand() {
  76. return gitIgnoreCommand;
  77. }
  78. @Override
  79. public String key() {
  80. return "git";
  81. }
  82. @Override
  83. public boolean supports(File baseDir) {
  84. RepositoryBuilder builder = new RepositoryBuilder().findGitDir(baseDir);
  85. return builder.getGitDir() != null;
  86. }
  87. @Override
  88. public BlameCommand blameCommand() {
  89. return this.jgitBlameCommand;
  90. }
  91. @CheckForNull
  92. @Override
  93. public Set<Path> branchChangedFiles(String targetBranchName, Path rootBaseDir) {
  94. try (Repository repo = buildRepo(rootBaseDir)) {
  95. Ref targetRef = resolveTargetRef(targetBranchName, repo);
  96. if (targetRef == null) {
  97. analysisWarnings.addUnique(String.format(COULD_NOT_FIND_REF
  98. + ". You may see unexpected issues and changes. "
  99. + "Please make sure to fetch this ref before pull request analysis.", 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. analysisWarnings.addUnique(String.format(COULD_NOT_FIND_REF
  137. + ". You may see unexpected issues and changes. "
  138. + "Please make sure to fetch this ref before pull request analysis.", targetBranchName));
  139. return null;
  140. }
  141. if (isDiffAlgoInvalid(repo.getConfig())) {
  142. // we already print a warning when branchChangedFiles is called
  143. return null;
  144. }
  145. // force ignore different line endings when comparing a commit with the workspace
  146. repo.getConfig().setBoolean("core", null, "autocrlf", true);
  147. Optional<RevCommit> mergeBaseCommit = findMergeBase(repo, targetRef);
  148. if (!mergeBaseCommit.isPresent()) {
  149. LOG.warn("No merge base found between HEAD and " + targetRef.getName());
  150. return null;
  151. }
  152. Map<Path, Set<Integer>> changedLines = new HashMap<>();
  153. Path repoRootDir = repo.getDirectory().toPath().getParent();
  154. for (Path path : changedFiles) {
  155. collectChangedLines(repo, mergeBaseCommit.get(), changedLines, repoRootDir, path);
  156. }
  157. return changedLines;
  158. } catch (Exception e) {
  159. LOG.warn("Failed to get changed lines from git", e);
  160. }
  161. return null;
  162. }
  163. private void collectChangedLines(Repository repo, RevCommit mergeBaseCommit, Map<Path, Set<Integer>> changedLines, Path repoRootDir, Path changedFile) {
  164. ChangedLinesComputer computer = new ChangedLinesComputer();
  165. try (DiffFormatter diffFmt = new DiffFormatter(new BufferedOutputStream(computer.receiver()))) {
  166. // copied from DiffCommand so that we can use a custom DiffFormatter which ignores white spaces.
  167. diffFmt.setRepository(repo);
  168. diffFmt.setProgressMonitor(NullProgressMonitor.INSTANCE);
  169. diffFmt.setDiffComparator(RawTextComparator.WS_IGNORE_ALL);
  170. diffFmt.setPathFilter(PathFilter.create(toGitPath(repoRootDir.relativize(changedFile).toString())));
  171. AbstractTreeIterator mergeBaseTree = prepareTreeParser(repo, mergeBaseCommit);
  172. List<DiffEntry> diffEntries = diffFmt.scan(mergeBaseTree, new FileTreeIterator(repo));
  173. diffFmt.format(diffEntries);
  174. diffFmt.flush();
  175. diffEntries.stream()
  176. .filter(diffEntry -> diffEntry.getChangeType() == DiffEntry.ChangeType.ADD || diffEntry.getChangeType() == DiffEntry.ChangeType.MODIFY)
  177. .findAny()
  178. .ifPresent(diffEntry -> changedLines.put(changedFile, computer.changedLines()));
  179. } catch (Exception e) {
  180. LOG.warn("Failed to get changed lines from git for file " + changedFile, e);
  181. }
  182. }
  183. @Override
  184. @CheckForNull
  185. public Instant forkDate(String referenceBranchName, Path projectBaseDir) {
  186. try (Repository repo = buildRepo(projectBaseDir)) {
  187. Ref targetRef = resolveTargetRef(referenceBranchName, repo);
  188. if (targetRef == null) {
  189. LOG.warn("Branch '{}' not found in git", referenceBranchName);
  190. return null;
  191. }
  192. if (isDiffAlgoInvalid(repo.getConfig())) {
  193. LOG.warn("The diff algorithm configured in git is not supported. "
  194. + "No information regarding changes in the branch will be collected, which can lead to unexpected results.");
  195. return null;
  196. }
  197. Optional<RevCommit> mergeBaseCommit = findMergeBase(repo, targetRef);
  198. if (!mergeBaseCommit.isPresent()) {
  199. LOG.warn("No fork point found between HEAD and " + targetRef.getName());
  200. return null;
  201. }
  202. return Instant.ofEpochSecond(mergeBaseCommit.get().getCommitTime());
  203. } catch (Exception e) {
  204. LOG.warn("Failed to find fork point with git", e);
  205. }
  206. return null;
  207. }
  208. private static String toGitPath(String path) {
  209. return path.replaceAll(Pattern.quote(File.separator), "/");
  210. }
  211. @CheckForNull
  212. private Ref resolveTargetRef(String targetBranchName, Repository repo) throws IOException {
  213. String localRef = "refs/heads/" + targetBranchName;
  214. String remotesRef = "refs/remotes/" + targetBranchName;
  215. String originRef = "refs/remotes/origin/" + targetBranchName;
  216. String upstreamRef = "refs/remotes/upstream/" + targetBranchName;
  217. Ref targetRef;
  218. // Because circle ci destroys the local reference to master, try to load remote ref first.
  219. // https://discuss.circleci.com/t/git-checkout-of-a-branch-destroys-local-reference-to-master/23781
  220. if (runningOnCircleCI()) {
  221. targetRef = getFirstExistingRef(repo, originRef, localRef, upstreamRef, remotesRef);
  222. } else {
  223. targetRef = getFirstExistingRef(repo, localRef, originRef, upstreamRef, remotesRef);
  224. }
  225. if (targetRef == null) {
  226. LOG.warn(COULD_NOT_FIND_REF, targetBranchName);
  227. }
  228. return targetRef;
  229. }
  230. @CheckForNull
  231. private static Ref getFirstExistingRef(Repository repo, String... refs) throws IOException {
  232. Ref targetRef = null;
  233. for (String ref : refs) {
  234. targetRef = repo.exactRef(ref);
  235. if (targetRef != null) {
  236. break;
  237. }
  238. }
  239. return targetRef;
  240. }
  241. private boolean runningOnCircleCI() {
  242. return "true".equals(system2.envVariable("CIRCLECI"));
  243. }
  244. @Override
  245. public Path relativePathFromScmRoot(Path path) {
  246. RepositoryBuilder builder = getVerifiedRepositoryBuilder(path);
  247. return builder.getGitDir().toPath().getParent().relativize(path);
  248. }
  249. @Override
  250. @CheckForNull
  251. public String revisionId(Path path) {
  252. RepositoryBuilder builder = getVerifiedRepositoryBuilder(path);
  253. try {
  254. Ref head = getHead(builder.build());
  255. if (head == null || head.getObjectId() == null) {
  256. // can happen on fresh, empty repos
  257. return null;
  258. }
  259. return head.getObjectId().getName();
  260. } catch (IOException e) {
  261. throw new IllegalStateException("I/O error while getting revision ID for path: " + path, e);
  262. }
  263. }
  264. private static boolean isDiffAlgoInvalid(Config cfg) {
  265. try {
  266. DiffAlgorithm.getAlgorithm(cfg.getEnum(
  267. ConfigConstants.CONFIG_DIFF_SECTION, null,
  268. ConfigConstants.CONFIG_KEY_ALGORITHM,
  269. DiffAlgorithm.SupportedAlgorithm.HISTOGRAM));
  270. return false;
  271. } catch (IllegalArgumentException e) {
  272. return true;
  273. }
  274. }
  275. private static AbstractTreeIterator prepareNewTree(Repository repo) throws IOException {
  276. CanonicalTreeParser treeParser = new CanonicalTreeParser();
  277. try (ObjectReader objectReader = repo.newObjectReader()) {
  278. Ref head = getHead(repo);
  279. if (head == null) {
  280. throw new IOException("HEAD reference not found");
  281. }
  282. treeParser.reset(objectReader, repo.parseCommit(head.getObjectId()).getTree());
  283. }
  284. return treeParser;
  285. }
  286. @CheckForNull
  287. private static Ref getHead(Repository repo) throws IOException {
  288. return repo.exactRef("HEAD");
  289. }
  290. private static Optional<RevCommit> findMergeBase(Repository repo, Ref targetRef) throws IOException {
  291. try (RevWalk walk = new RevWalk(repo)) {
  292. Ref head = getHead(repo);
  293. if (head == null) {
  294. throw new IOException("HEAD reference not found");
  295. }
  296. walk.markStart(walk.parseCommit(targetRef.getObjectId()));
  297. walk.markStart(walk.parseCommit(head.getObjectId()));
  298. walk.setRevFilter(RevFilter.MERGE_BASE);
  299. RevCommit next = walk.next();
  300. if (next == null) {
  301. return Optional.empty();
  302. }
  303. RevCommit base = walk.parseCommit(next);
  304. walk.dispose();
  305. LOG.debug("Merge base sha1: {}", base.getName());
  306. return Optional.of(base);
  307. }
  308. }
  309. AbstractTreeIterator prepareTreeParser(Repository repo, RevCommit commit) throws IOException {
  310. CanonicalTreeParser treeParser = new CanonicalTreeParser();
  311. try (ObjectReader objectReader = repo.newObjectReader()) {
  312. treeParser.reset(objectReader, commit.getTree());
  313. }
  314. return treeParser;
  315. }
  316. Git newGit(Repository repo) {
  317. return new Git(repo);
  318. }
  319. Repository buildRepo(Path basedir) throws IOException {
  320. return getVerifiedRepositoryBuilder(basedir).build();
  321. }
  322. static RepositoryBuilder getVerifiedRepositoryBuilder(Path basedir) {
  323. RepositoryBuilder builder = new RepositoryBuilder()
  324. .findGitDir(basedir.toFile())
  325. .setMustExist(true);
  326. if (builder.getGitDir() == null) {
  327. throw MessageException.of("Not inside a Git work tree: " + basedir);
  328. }
  329. return builder;
  330. }
  331. }