From 4e770aa94bc813d6c8598eb86ec283fd48dfd970 Mon Sep 17 00:00:00 2001 From: Duarte Meneses Date: Wed, 13 Apr 2022 17:42:07 -0500 Subject: SONAR-16290 Use native git to collect blame information --- .../org/sonar/scm/git/CompositeBlameCommand.java | 119 +++++++++ .../java/org/sonar/scm/git/GitBlameCommand.java | 211 +++++++-------- .../java/org/sonar/scm/git/GitScmProvider.java | 8 +- .../main/java/org/sonar/scm/git/GitScmSupport.java | 2 + .../java/org/sonar/scm/git/JGitBlameCommand.java | 82 +----- .../sonar/scm/git/CompositeBlameCommandTest.java | 287 ++++++++++++++++++++ .../org/sonar/scm/git/GitBlameCommandTest.java | 187 ++++++++++--- .../java/org/sonar/scm/git/GitScmProviderTest.java | 27 +- .../java/org/sonar/scm/git/GitScmSupportTest.java | 2 +- .../src/test/java/org/sonar/scm/git/GitUtils.java | 44 +++ .../org/sonar/scm/git/JGitBlameCommandTest.java | 295 ++++----------------- 11 files changed, 782 insertions(+), 482 deletions(-) create mode 100644 sonar-scanner-engine/src/main/java/org/sonar/scm/git/CompositeBlameCommand.java create mode 100644 sonar-scanner-engine/src/test/java/org/sonar/scm/git/CompositeBlameCommandTest.java create mode 100644 sonar-scanner-engine/src/test/java/org/sonar/scm/git/GitUtils.java (limited to 'sonar-scanner-engine') diff --git a/sonar-scanner-engine/src/main/java/org/sonar/scm/git/CompositeBlameCommand.java b/sonar-scanner-engine/src/main/java/org/sonar/scm/git/CompositeBlameCommand.java new file mode 100644 index 00000000000..83223959ee8 --- /dev/null +++ b/sonar-scanner-engine/src/main/java/org/sonar/scm/git/CompositeBlameCommand.java @@ -0,0 +1,119 @@ +/* + * SonarQube + * Copyright (C) 2009-2022 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.scm.git; + +import java.io.File; +import java.nio.file.Files; +import java.util.List; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.lib.Repository; +import org.sonar.api.batch.fs.InputFile; +import org.sonar.api.batch.scm.BlameCommand; +import org.sonar.api.batch.scm.BlameLine; +import org.sonar.api.notifications.AnalysisWarnings; +import org.sonar.api.scan.filesystem.PathResolver; +import org.sonar.api.utils.log.Logger; +import org.sonar.api.utils.log.Loggers; + +public class CompositeBlameCommand extends BlameCommand { + private static final Logger LOG = Loggers.get(CompositeBlameCommand.class); + + private final AnalysisWarnings analysisWarnings; + private final PathResolver pathResolver; + private final JGitBlameCommand jgitCmd; + private final GitBlameCommand nativeCmd; + private boolean nativeGitEnabled = false; + + public CompositeBlameCommand(AnalysisWarnings analysisWarnings, PathResolver pathResolver, JGitBlameCommand jgitCmd, GitBlameCommand nativeCmd) { + this.analysisWarnings = analysisWarnings; + this.pathResolver = pathResolver; + this.jgitCmd = jgitCmd; + this.nativeCmd = nativeCmd; + } + + @Override + public void blame(BlameInput input, BlameOutput output) { + File basedir = input.fileSystem().baseDir(); + try (Repository repo = JGitUtils.buildRepository(basedir.toPath()); Git git = Git.wrap(repo)) { + File gitBaseDir = repo.getWorkTree(); + if (cloneIsInvalid(gitBaseDir)) { + return; + } + nativeGitEnabled = nativeCmd.isEnabled(basedir.toPath()); + Stream stream = StreamSupport.stream(input.filesToBlame().spliterator(), true); + ForkJoinPool forkJoinPool = new ForkJoinPool(Runtime.getRuntime().availableProcessors(), new GitThreadFactory(), null, false); + // exceptions thrown by the blame method will be ignored + forkJoinPool.submit(() -> stream.forEach(inputFile -> blame(output, git, gitBaseDir, inputFile))); + try { + forkJoinPool.shutdown(); + forkJoinPool.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS); + } catch (InterruptedException e) { + LOG.info("Git blame interrupted"); + } + } + } + + private void blame(BlameOutput output, Git git, File gitBaseDir, InputFile inputFile) { + String filename = pathResolver.relativePath(gitBaseDir, inputFile.file()); + LOG.debug("Blame file {}", filename); + List blame = null; + if (nativeGitEnabled) { + try { + blame = nativeCmd.blame(gitBaseDir.toPath(), filename); + } catch (Exception e) { + // fallback to jgit + } + } + + if (blame == null) { + blame = jgitCmd.blame(git, filename); + } + + if (!blame.isEmpty()) { + if (blame.size() == inputFile.lines() - 1) { + // SONARPLUGINS-3097 Git do not report blame on last empty line + blame.add(blame.get(blame.size() - 1)); + } + output.blameResult(inputFile, blame); + } + } + + private boolean cloneIsInvalid(File gitBaseDir) { + if (Files.isRegularFile(gitBaseDir.toPath().resolve(".git/objects/info/alternates"))) { + LOG.info("This git repository references another local repository which is not well supported. SCM information might be missing for some files. " + + "You can avoid borrow objects from another local repository by not using --reference or --shared when cloning it."); + } + + if (Files.isRegularFile(gitBaseDir.toPath().resolve(".git/shallow"))) { + LOG.warn("Shallow clone detected, no blame information will be provided. " + + "You can convert to non-shallow with 'git fetch --unshallow'."); + analysisWarnings.addUnique("Shallow clone detected during the analysis. " + + "Some files will miss SCM information. This will affect features like auto-assignment of issues. " + + "Please configure your build to disable shallow clone."); + return true; + } + + return false; + } +} diff --git a/sonar-scanner-engine/src/main/java/org/sonar/scm/git/GitBlameCommand.java b/sonar-scanner-engine/src/main/java/org/sonar/scm/git/GitBlameCommand.java index 856a1503ec9..3fffbe0ea83 100644 --- a/sonar-scanner-engine/src/main/java/org/sonar/scm/git/GitBlameCommand.java +++ b/sonar-scanner-engine/src/main/java/org/sonar/scm/git/GitBlameCommand.java @@ -20,156 +20,143 @@ package org.sonar.scm.git; import java.io.BufferedReader; -import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; -import java.nio.file.Files; import java.nio.file.Path; +import java.time.Instant; import java.util.ArrayList; import java.util.Date; -import java.util.HashMap; import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; -import org.apache.commons.lang.StringUtils; +import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.sonar.api.batch.scm.BlameLine; +import org.sonar.api.utils.log.Logger; +import org.sonar.api.utils.log.Loggers; +import org.springframework.beans.factory.annotation.Autowired; -import static java.util.Objects.requireNonNull; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Collections.emptyList; +import static java.util.Collections.unmodifiableList; +import static org.sonar.api.utils.Preconditions.checkState; public class GitBlameCommand { - private static final String AUTHOR = "author"; - private static final String COMMIT = "commit"; - private static final String TIMESTAMP = "timestamp"; - private static final String TIMEZONE = "timezone"; - private static final String LINE = "line"; + private static final Logger LOG = Loggers.get(GitBlameCommand.class); + private static final Pattern EMAIL_PATTERN = Pattern.compile("<(\\S*?)>"); + private static final String COMITTER_TIME = "committer-time "; + private static final String COMITTER_MAIL = "committer-mail "; private static final String GIT_COMMAND = "git"; private static final String BLAME_COMMAND = "blame"; - private static final String BLAME_LONG_FLAG = "-l"; - private static final String BLAME_SHOW_EMAIL_FLAG = "--show-email"; - private static final String BLAME_TIMESTAMP_FLAG = "-t"; + private static final String BLAME_LINE_PORCELAIN_FLAG = "--line-porcelain"; + private static final String IGNORE_WHITESPACES = "-w"; - public static List executeCommand(Path directory, String... command) throws IOException, InterruptedException { - requireNonNull(directory, "directory"); + private final String gitCommand; - if (!Files.exists(directory)) { - throw new RuntimeException("Directory does not exist, unable to run git operations:'" + directory + "'"); + @Autowired + public GitBlameCommand() { + this(GIT_COMMAND); + } + + public GitBlameCommand(String gitCommand) { + this.gitCommand = gitCommand; + } + + public boolean isEnabled(Path baseDir) { + try { + MutableString stdOut = new MutableString(); + executeCommand(baseDir, l -> stdOut.string = l, gitCommand, "--version"); + return stdOut.string != null && stdOut.string.startsWith("git version"); + } catch (Exception e) { + LOG.debug("Failed to find git native client", e); + return false; + } + } + + public List blame(Path baseDir, String fileName) { + BlameOutputProcessor outputProcessor = new BlameOutputProcessor(); + try { + executeCommand(baseDir, outputProcessor::process, gitCommand, BLAME_COMMAND, BLAME_LINE_PORCELAIN_FLAG, IGNORE_WHITESPACES, fileName); + return outputProcessor.getBlameLines(); + } catch (Exception e) { + LOG.debug("Blame failed for " + fileName, e); + return emptyList(); } + } + private void executeCommand(Path baseDir, Consumer stdOutLineConsumer, String... command) throws Exception { ProcessBuilder pb = new ProcessBuilder() .command(command) - .directory(directory.toFile()); + .directory(baseDir.toFile()); Process p = pb.start(); - - List commandOutput = new ArrayList<>(); - InputStream processStdOutput = p.getInputStream(); - - try (BufferedReader br = new BufferedReader(new InputStreamReader(processStdOutput))) { + try { + InputStream processStdOutput = p.getInputStream(); + try (BufferedReader br = new BufferedReader(new InputStreamReader(processStdOutput, UTF_8))) { String outputLine; - while ((outputLine = br.readLine()) != null) { - commandOutput.add(outputLine); + stdOutLineConsumer.accept(outputLine); } + } - int exit = p.waitFor(); - - if (exit != 0) { - throw new AssertionError(String.format("Command execution exited with code: %d", exit)); - } + int exit = p.waitFor(); + if (exit != 0) { + throw new IllegalStateException(String.format("Command execution exited with code: %d", exit)); + } - } catch (Exception e) { - e.printStackTrace(); } finally { p.destroy(); } - - return commandOutput - .stream() - .map(GitBlameCommand::parseBlameLine) - .collect(Collectors.toList()); } - private static Map getBlameAuthoringData(String blameLine) { - String[] blameLineFormatted = blameLine.trim().split("\\s+", 2); - - String commit = blameLineFormatted[0]; + private static class BlameOutputProcessor { + private final List blameLines = new ArrayList<>(); + private String sha1; + private String committerTime; + private String committerMail; - if (commit.length() != 40) { - throw new IllegalStateException(String.format("Failed to fetch correct commit hash, must be of length 40: %s", commit)); + public List getBlameLines() { + return unmodifiableList(blameLines); } - String authoringData = StringUtils.substringBetween(blameLineFormatted[1], "(", ")"); - String[] authoringDataFormatted = authoringData.trim().split("\\s+", 4); - - String author = StringUtils.substringBetween(authoringDataFormatted[0], "<", ">"); - String timestamp = authoringDataFormatted[1]; - String timezone = authoringDataFormatted[2]; - String line = authoringDataFormatted[3]; - - Map blameData = new HashMap<>(); - - blameData.put(COMMIT, commit); - blameData.put(AUTHOR, author); - blameData.put(TIMESTAMP, timestamp); - blameData.put(TIMEZONE, timezone); - blameData.put(LINE, line); - - return blameData; - } - - private static BlameLine parseBlameLine(String blameLine) { - Map blameData = getBlameAuthoringData(blameLine); - - return new BlameLine() - .date(new Date(Long.parseLong(blameData.get(TIMESTAMP)))) // should also take timezone into consideration - .revision(blameData.get(COMMIT)) - .author(blameData.get(AUTHOR)); - } - - public static void gitInit(Path directory) throws IOException, InterruptedException { - executeCommand(directory, "git", "init"); - } - - public static void gitStage(Path directory) throws IOException, InterruptedException { - executeCommand(directory, "git", "add", "-A"); - } - - public static void gitCommit(Path directory, String message) throws IOException, InterruptedException { - executeCommand(directory, GIT_COMMAND, COMMIT, "-m", message); - } - - public static void gitClone(Path directory, String originUrl) throws IOException, InterruptedException { - executeCommand(directory.getParent(), "git", "clone", originUrl, directory.getFileName().toString()); - } - - public static List gitBlame(Path directory, String fileName) throws IOException, InterruptedException { - return executeCommand(directory, GIT_COMMAND, BLAME_COMMAND, BLAME_LONG_FLAG, BLAME_SHOW_EMAIL_FLAG, BLAME_TIMESTAMP_FLAG, fileName); - } - - private static class StreamGobbler extends Thread { - private final InputStream is; - private final String type; - - private StreamGobbler(InputStream is, String type) { - this.is = is; - this.type = type; + public void process(String line) { + if (sha1 == null) { + sha1 = line.split(" ")[0]; + } else if (line.startsWith("\t")) { + saveEntry(); + } else if (line.startsWith(COMITTER_TIME)) { + committerTime = line.substring(COMITTER_TIME.length()); + } else if (line.startsWith(COMITTER_MAIL)) { + Matcher matcher = EMAIL_PATTERN.matcher(line); + if (!matcher.find(COMITTER_MAIL.length()) || matcher.groupCount() != 1) { + throw new IllegalStateException("Couldn't parse committer email from: " + line); + } + committerMail = matcher.group(1); + if (committerMail.equals("not.committed.yet")) { + throw new IllegalStateException("Uncommitted line found"); + } + } } - @Override - public void run() { - try (BufferedReader br = new BufferedReader(new InputStreamReader(is));) { - List commandOutput = new ArrayList<>(); - String outputLine; - - while ((outputLine = br.readLine()) != null) { - commandOutput.add(outputLine); - System.out.println(type + "> " + outputLine); - } - } catch (IOException ioe) { - ioe.printStackTrace(); + private void saveEntry() { + checkState(committerMail != null, "Did not find a committer email for an entry"); + checkState(committerTime != null, "Did not find a committer time for an entry"); + checkState(sha1 != null, "Did not find a commit sha1 for an entry"); + try { + blameLines.add(new BlameLine() + .revision(sha1) + .author(committerMail) + .date(Date.from(Instant.ofEpochSecond(Long.parseLong(committerTime))))); + } catch (NumberFormatException e) { + throw new IllegalStateException("Invalid committer time found: " + committerTime); } + committerMail = null; + sha1 = null; + committerTime = null; } } + private static class MutableString { + String string; + } } diff --git a/sonar-scanner-engine/src/main/java/org/sonar/scm/git/GitScmProvider.java b/sonar-scanner-engine/src/main/java/org/sonar/scm/git/GitScmProvider.java index e66e4f79303..bef15b669bc 100644 --- a/sonar-scanner-engine/src/main/java/org/sonar/scm/git/GitScmProvider.java +++ b/sonar-scanner-engine/src/main/java/org/sonar/scm/git/GitScmProvider.java @@ -64,14 +64,14 @@ public class GitScmProvider extends ScmProvider { private static final Logger LOG = Loggers.get(GitScmProvider.class); 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"; - private final JGitBlameCommand jgitBlameCommand; + private final BlameCommand blameCommand; private final AnalysisWarnings analysisWarnings; private final GitIgnoreCommand gitIgnoreCommand; private final System2 system2; private final String documentationLink; - public GitScmProvider(JGitBlameCommand jgitBlameCommand, AnalysisWarnings analysisWarnings, GitIgnoreCommand gitIgnoreCommand, System2 system2) { - this.jgitBlameCommand = jgitBlameCommand; + public GitScmProvider(CompositeBlameCommand blameCommand, AnalysisWarnings analysisWarnings, GitIgnoreCommand gitIgnoreCommand, System2 system2) { + this.blameCommand = blameCommand; this.analysisWarnings = analysisWarnings; this.gitIgnoreCommand = gitIgnoreCommand; this.system2 = system2; @@ -96,7 +96,7 @@ public class GitScmProvider extends ScmProvider { @Override public BlameCommand blameCommand() { - return this.jgitBlameCommand; + return this.blameCommand; } @CheckForNull diff --git a/sonar-scanner-engine/src/main/java/org/sonar/scm/git/GitScmSupport.java b/sonar-scanner-engine/src/main/java/org/sonar/scm/git/GitScmSupport.java index 2d412fe74b2..a67bd56320d 100644 --- a/sonar-scanner-engine/src/main/java/org/sonar/scm/git/GitScmSupport.java +++ b/sonar-scanner-engine/src/main/java/org/sonar/scm/git/GitScmSupport.java @@ -32,6 +32,8 @@ public final class GitScmSupport { FS.FileStoreAttributes.setBackground(true); return Arrays.asList( JGitBlameCommand.class, + CompositeBlameCommand.class, + GitBlameCommand.class, GitScmProvider.class, GitIgnoreCommand.class); } diff --git a/sonar-scanner-engine/src/main/java/org/sonar/scm/git/JGitBlameCommand.java b/sonar-scanner-engine/src/main/java/org/sonar/scm/git/JGitBlameCommand.java index f939ceb28e4..e9868a03c52 100644 --- a/sonar-scanner-engine/src/main/java/org/sonar/scm/git/JGitBlameCommand.java +++ b/sonar-scanner-engine/src/main/java/org/sonar/scm/git/JGitBlameCommand.java @@ -19,80 +19,21 @@ */ package org.sonar.scm.git; -import java.io.File; -import java.nio.file.Files; import java.util.ArrayList; import java.util.List; -import java.util.concurrent.ForkJoinPool; -import java.util.concurrent.TimeUnit; -import java.util.stream.Stream; -import java.util.stream.StreamSupport; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.blame.BlameResult; import org.eclipse.jgit.diff.RawTextComparator; -import org.eclipse.jgit.lib.Repository; -import org.sonar.api.batch.fs.InputFile; -import org.sonar.api.batch.scm.BlameCommand; import org.sonar.api.batch.scm.BlameLine; -import org.sonar.api.notifications.AnalysisWarnings; -import org.sonar.api.scan.filesystem.PathResolver; import org.sonar.api.utils.log.Logger; import org.sonar.api.utils.log.Loggers; -public class JGitBlameCommand extends BlameCommand { +import static java.util.Collections.emptyList; +public class JGitBlameCommand { private static final Logger LOG = Loggers.get(JGitBlameCommand.class); - private final PathResolver pathResolver; - private final AnalysisWarnings analysisWarnings; - - public JGitBlameCommand(PathResolver pathResolver, AnalysisWarnings analysisWarnings) { - this.pathResolver = pathResolver; - this.analysisWarnings = analysisWarnings; - } - - @Override - public void blame(BlameInput input, BlameOutput output) { - File basedir = input.fileSystem().baseDir(); - try (Repository repo = JGitUtils.buildRepository(basedir.toPath()); Git git = Git.wrap(repo)) { - File gitBaseDir = repo.getWorkTree(); - - if (cloneIsInvalid(gitBaseDir)) { - return; - } - - Stream stream = StreamSupport.stream(input.filesToBlame().spliterator(), true); - ForkJoinPool forkJoinPool = new ForkJoinPool(Runtime.getRuntime().availableProcessors(), new GitThreadFactory(), null, false); - forkJoinPool.submit(() -> stream.forEach(inputFile -> blame(output, git, gitBaseDir, inputFile))); - try { - forkJoinPool.shutdown(); - forkJoinPool.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS); - } catch (InterruptedException e) { - LOG.info("Git blame interrupted"); - } - } - } - - private boolean cloneIsInvalid(File gitBaseDir) { - if (Files.isRegularFile(gitBaseDir.toPath().resolve(".git/objects/info/alternates"))) { - LOG.info("This git repository references another local repository which is not well supported. SCM information might be missing for some files. " - + "You can avoid borrow objects from another local repository by not using --reference or --shared when cloning it."); - } - - if (Files.isRegularFile(gitBaseDir.toPath().resolve(".git/shallow"))) { - LOG.warn("Shallow clone detected, no blame information will be provided. " - + "You can convert to non-shallow with 'git fetch --unshallow'."); - analysisWarnings.addUnique("Shallow clone detected during the analysis. " - + "Some files will miss SCM information. This will affect features like auto-assignment of issues. " - + "Please configure your build to disable shallow clone."); - return true; - } - - return false; - } - - private void blame(BlameOutput output, Git git, File gitBaseDir, InputFile inputFile) { - String filename = pathResolver.relativePath(gitBaseDir, inputFile.file()); + public List blame(Git git, String filename) { LOG.debug("Blame file {}", filename); BlameResult blameResult; try { @@ -101,29 +42,26 @@ public class JGitBlameCommand extends BlameCommand { .setTextComparator(RawTextComparator.WS_IGNORE_ALL) .setFilePath(filename).call(); } catch (Exception e) { - throw new IllegalStateException("Unable to blame file " + inputFile.relativePath(), e); + throw new IllegalStateException("Unable to blame file " + filename, e); } List lines = new ArrayList<>(); if (blameResult == null) { - LOG.debug("Unable to blame file {}. It is probably a symlink.", inputFile.relativePath()); - return; + LOG.debug("Unable to blame file {}. It is probably a symlink.", filename); + return emptyList(); } for (int i = 0; i < blameResult.getResultContents().size(); i++) { if (blameResult.getSourceAuthor(i) == null || blameResult.getSourceCommit(i) == null) { - LOG.debug("Unable to blame file {}. No blame info at line {}. Is file committed? [Author: {} Source commit: {}]", inputFile.relativePath(), i + 1, + LOG.debug("Unable to blame file {}. No blame info at line {}. Is file committed? [Author: {} Source commit: {}]", filename, i + 1, blameResult.getSourceAuthor(i), blameResult.getSourceCommit(i)); - return; + return emptyList(); } lines.add(new BlameLine() .date(blameResult.getSourceCommitter(i).getWhen()) .revision(blameResult.getSourceCommit(i).getName()) .author(blameResult.getSourceAuthor(i).getEmailAddress())); } - if (lines.size() == inputFile.lines() - 1) { - // SONARPLUGINS-3097 Git do not report blame on last empty line - lines.add(lines.get(lines.size() - 1)); - } - output.blameResult(inputFile, lines); + + return lines; } } diff --git a/sonar-scanner-engine/src/test/java/org/sonar/scm/git/CompositeBlameCommandTest.java b/sonar-scanner-engine/src/test/java/org/sonar/scm/git/CompositeBlameCommandTest.java new file mode 100644 index 00000000000..af610eae2c5 --- /dev/null +++ b/sonar-scanner-engine/src/test/java/org/sonar/scm/git/CompositeBlameCommandTest.java @@ -0,0 +1,287 @@ +/* + * SonarQube + * Copyright (C) 2009-2022 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.scm.git; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.apache.commons.io.FileUtils; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.sonar.api.batch.fs.InputFile; +import org.sonar.api.batch.fs.internal.DefaultFileSystem; +import org.sonar.api.batch.fs.internal.DefaultInputFile; +import org.sonar.api.batch.fs.internal.TestInputFileBuilder; +import org.sonar.api.batch.scm.BlameCommand; +import org.sonar.api.batch.scm.BlameLine; +import org.sonar.api.notifications.AnalysisWarnings; +import org.sonar.api.scan.filesystem.PathResolver; +import org.sonar.api.utils.DateUtils; +import org.sonar.api.utils.MessageException; +import org.sonar.api.utils.System2; +import org.sonar.api.utils.log.LogTester; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.Assume.assumeTrue; +import static org.mockito.ArgumentMatchers.startsWith; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.sonar.scm.git.Utils.javaUnzip; + +public class CompositeBlameCommandTest { + private static final String DUMMY_JAVA = "src/main/java/org/dummy/Dummy.java"; + private final PathResolver pathResolver = new PathResolver(); + private final JGitBlameCommand jGitBlameCommand = new JGitBlameCommand(); + private final GitBlameCommand gitBlameCommand = new GitBlameCommand(); + private final AnalysisWarnings analysisWarnings = mock(AnalysisWarnings.class); + private final CompositeBlameCommand blameCommand = new CompositeBlameCommand(analysisWarnings, pathResolver, jGitBlameCommand, gitBlameCommand); + + @Rule + public TemporaryFolder temp = new TemporaryFolder(); + + @Rule + public LogTester logTester = new LogTester(); + private final BlameCommand.BlameInput input = mock(BlameCommand.BlameInput.class); + + @Test + public void use_jgit_if_native_git_disabled() throws IOException { + GitBlameCommand gitCmd = new GitBlameCommand("invalidcommandnotfound"); + BlameCommand blameCmd = new CompositeBlameCommand(analysisWarnings, pathResolver, jGitBlameCommand, gitCmd); + File projectDir = createNewTempFolder(); + javaUnzip("dummy-git.zip", projectDir); + + File baseDir = new File(projectDir, "dummy-git"); + setUpBlameInputWithFile(baseDir.toPath()); + TestBlameOutput output = new TestBlameOutput(); + blameCmd.blame(input, output); + assertThat(output.blame).hasSize(1); + assertThat(output.blame.get(input.filesToBlame().iterator().next())).hasSize(29); + } + + @Test + public void fallback_to_jgit_if_native_git_fails() throws IOException { + GitBlameCommand gitCmd = mock(GitBlameCommand.class); + BlameCommand blameCmd = new CompositeBlameCommand(analysisWarnings, pathResolver, jGitBlameCommand, gitCmd); + File projectDir = createNewTempFolder(); + javaUnzip("dummy-git.zip", projectDir); + + File baseDir = new File(projectDir, "dummy-git"); + when(gitCmd.isEnabled(baseDir.toPath())).thenReturn(true); + when(gitCmd.blame(baseDir.toPath(), DUMMY_JAVA)).thenThrow(new IllegalStateException()); + setUpBlameInputWithFile(baseDir.toPath()); + TestBlameOutput output = new TestBlameOutput(); + blameCmd.blame(input, output); + assertThat(output.blame).hasSize(1); + assertThat(output.blame.get(input.filesToBlame().iterator().next())).hasSize(29); + } + + @Test + public void use_native_git_by_default() throws IOException { + File projectDir = createNewTempFolder(); + javaUnzip("dummy-git.zip", projectDir); + File baseDir = new File(projectDir, "dummy-git"); + + // skip test if git is not installed + assumeTrue(gitBlameCommand.isEnabled(baseDir.toPath())); + + JGitBlameCommand jgit = mock(JGitBlameCommand.class); + BlameCommand blameCmd = new CompositeBlameCommand(analysisWarnings, pathResolver, jgit, gitBlameCommand); + + setUpBlameInputWithFile(baseDir.toPath()); + TestBlameOutput output = new TestBlameOutput(); + blameCmd.blame(input, output); + assertThat(output.blame).hasSize(1); + assertThat(output.blame.get(input.filesToBlame().iterator().next())).hasSize(29); + verifyNoInteractions(jgit); + } + + @Test + public void return_early_when_shallow_clone_detected() throws IOException { + File projectDir = createNewTempFolder(); + javaUnzip("shallow-git.zip", projectDir); + + File baseDir = new File(projectDir, "shallow-git"); + + setUpBlameInputWithFile(baseDir.toPath()); + + // register warning with default wrapper + BlameCommand.BlameOutput output = mock(BlameCommand.BlameOutput.class); + blameCommand.blame(input, output); + + assertThat(logTester.logs()).first() + .matches(s -> s.contains("Shallow clone detected, no blame information will be provided.")); + verifyNoInteractions(output); + + verify(analysisWarnings).addUnique(startsWith("Shallow clone detected")); + } + + @Test + public void fail_if_not_git_project() throws IOException { + File projectDir = createNewTempFolder(); + javaUnzip("dummy-git.zip", projectDir); + + File baseDir = new File(projectDir, "dummy-git"); + + // Delete .git + FileUtils.forceDelete(new File(baseDir, ".git")); + + setUpBlameInputWithFile(baseDir.toPath()); + + BlameCommand.BlameOutput blameResult = mock(BlameCommand.BlameOutput.class); + + assertThatThrownBy(() -> blameCommand.blame(input, blameResult)) + .isInstanceOf(MessageException.class) + .hasMessageContaining("Not inside a Git work tree: "); + } + + @Test + public void dont_fail_with_symlink() throws IOException { + assumeTrue(!System2.INSTANCE.isOsWindows()); + File projectDir = createNewTempFolder(); + javaUnzip("dummy-git.zip", projectDir); + + File baseDir = new File(projectDir, "dummy-git"); + String relativePath2 = "src/main/java/org/dummy/Dummy2.java"; + DefaultInputFile inputFile = new TestInputFileBuilder("foo", DUMMY_JAVA) + .setModuleBaseDir(baseDir.toPath()) + .build(); + DefaultInputFile inputFile2 = new TestInputFileBuilder("foo", relativePath2) + .setModuleBaseDir(baseDir.toPath()) + .build(); + + // Create symlink + Files.createSymbolicLink(inputFile2.file().toPath(), inputFile.file().toPath()); + + when(input.filesToBlame()).thenReturn(Arrays.asList(inputFile, inputFile2)); + TestBlameOutput output = new TestBlameOutput(); + blameCommand.blame(input, output); + } + + + @Test + public void return_early_when_clone_with_reference_detected() throws IOException { + File projectDir = createNewTempFolder(); + javaUnzip("dummy-git-reference-clone.zip", projectDir); + + Path baseDir = projectDir.toPath().resolve("dummy-git2"); + + DefaultFileSystem fs = new DefaultFileSystem(baseDir); + when(input.fileSystem()).thenReturn(fs); + + DefaultInputFile inputFile = new TestInputFileBuilder("foo", DUMMY_JAVA).setModuleBaseDir(baseDir).build(); + when(input.filesToBlame()).thenReturn(Collections.singleton(inputFile)); + + // register warning + TestBlameOutput output = new TestBlameOutput(); + blameCommand.blame(input, output); + + assertThat(logTester.logs()).first() + .matches(s -> s.contains("This git repository references another local repository which is not well supported")); + + // contains commits referenced from the old clone and commits in the new clone + assertThat(output.blame).containsKey(inputFile); + assertThat(output.blame.get(inputFile).stream().map(BlameLine::revision)) + .containsOnly("6b3aab35a3ea32c1636fee56f996e677653c48ea", "843c7c30d7ebd9a479e8f1daead91036c75cbc4e", "0d269c1acfb8e6d4d33f3c43041eb87e0df0f5e7"); + verifyNoInteractions(analysisWarnings); + } + + @Test + public void blame_on_nested_module() throws IOException { + File projectDir = createNewTempFolder(); + javaUnzip("dummy-git-nested.zip", projectDir); + File baseDir = new File(projectDir, "dummy-git-nested/dummy-project"); + DefaultFileSystem fs = new DefaultFileSystem(baseDir); + when(input.fileSystem()).thenReturn(fs); + DefaultInputFile inputFile = new TestInputFileBuilder("foo", DUMMY_JAVA) + .setModuleBaseDir(baseDir.toPath()) + .build(); + fs.add(inputFile); + + BlameCommand.BlameOutput blameResult = mock(BlameCommand.BlameOutput.class); + when(input.filesToBlame()).thenReturn(Arrays.asList(inputFile)); + blameCommand.blame(input, blameResult); + + Date revisionDate = DateUtils.parseDateTime("2012-07-17T16:12:48+0200"); + String revision = "6b3aab35a3ea32c1636fee56f996e677653c48ea"; + String author = "david@gageot.net"; + verify(blameResult).blameResult(inputFile, + Arrays.asList( + new BlameLine().revision(revision).date(revisionDate).author(author), + new BlameLine().revision(revision).date(revisionDate).author(author), + new BlameLine().revision(revision).date(revisionDate).author(author), + new BlameLine().revision(revision).date(revisionDate).author(author), + new BlameLine().revision(revision).date(revisionDate).author(author), + new BlameLine().revision(revision).date(revisionDate).author(author), + new BlameLine().revision(revision).date(revisionDate).author(author), + new BlameLine().revision(revision).date(revisionDate).author(author), + new BlameLine().revision(revision).date(revisionDate).author(author), + new BlameLine().revision(revision).date(revisionDate).author(author), + new BlameLine().revision(revision).date(revisionDate).author(author), + new BlameLine().revision(revision).date(revisionDate).author(author), + new BlameLine().revision(revision).date(revisionDate).author(author), + new BlameLine().revision(revision).date(revisionDate).author(author), + new BlameLine().revision(revision).date(revisionDate).author(author), + new BlameLine().revision(revision).date(revisionDate).author(author), + new BlameLine().revision(revision).date(revisionDate).author(author), + new BlameLine().revision(revision).date(revisionDate).author(author), + new BlameLine().revision(revision).date(revisionDate).author(author), + new BlameLine().revision(revision).date(revisionDate).author(author), + new BlameLine().revision(revision).date(revisionDate).author(author), + new BlameLine().revision(revision).date(revisionDate).author(author), + new BlameLine().revision(revision).date(revisionDate).author(author), + new BlameLine().revision(revision).date(revisionDate).author(author), + new BlameLine().revision(revision).date(revisionDate).author(author), + new BlameLine().revision(revision).date(revisionDate).author(author))); + } + + private BlameCommand.BlameInput setUpBlameInputWithFile(Path baseDir) { + DefaultFileSystem fs = new DefaultFileSystem(baseDir); + when(input.fileSystem()).thenReturn(fs); + + DefaultInputFile inputFile = new TestInputFileBuilder("foo", DUMMY_JAVA).setModuleBaseDir(baseDir).build(); + when(input.filesToBlame()).thenReturn(Collections.singleton(inputFile)); + return input; + } + + private File createNewTempFolder() throws IOException { + //This is needed for Windows, otherwise the created File point to invalid (shortened by Windows) temp folder path + return temp.newFolder().toPath().toRealPath(LinkOption.NOFOLLOW_LINKS).toFile(); + } + + private static class TestBlameOutput implements BlameCommand.BlameOutput { + private final Map> blame = new LinkedHashMap<>(); + + @Override public void blameResult(InputFile inputFile, List list) { + blame.put(inputFile, list); + } + } +} diff --git a/sonar-scanner-engine/src/test/java/org/sonar/scm/git/GitBlameCommandTest.java b/sonar-scanner-engine/src/test/java/org/sonar/scm/git/GitBlameCommandTest.java index 2785ceeb7ad..0de3252668c 100644 --- a/sonar-scanner-engine/src/test/java/org/sonar/scm/git/GitBlameCommandTest.java +++ b/sonar-scanner-engine/src/test/java/org/sonar/scm/git/GitBlameCommandTest.java @@ -1,51 +1,178 @@ +/* + * SonarQube + * Copyright (C) 2009-2022 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ package org.sonar.scm.git; +import java.io.File; import java.io.IOException; import java.nio.file.Files; +import java.nio.file.LinkOption; import java.nio.file.Path; -import java.nio.file.Paths; +import java.util.Date; +import java.util.LinkedList; import java.util.List; +import org.apache.commons.io.FileUtils; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; import org.sonar.api.batch.scm.BlameLine; +import org.sonar.api.utils.DateUtils; +import org.sonar.api.utils.System2; +import org.sonar.api.utils.log.LogTester; -import static org.sonar.scm.git.GitBlameCommand.gitBlame; -import static org.sonar.scm.git.GitBlameCommand.gitClone; -import static org.sonar.scm.git.GitBlameCommand.gitCommit; -import static org.sonar.scm.git.GitBlameCommand.gitInit; -import static org.sonar.scm.git.GitBlameCommand.gitStage; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assume.assumeTrue; +import static org.sonar.scm.git.GitUtils.createFile; +import static org.sonar.scm.git.GitUtils.createRepository; +import static org.sonar.scm.git.Utils.javaUnzip; public class GitBlameCommandTest { - private static final String ORIGIN_URL = "https://github.com/klaussinani/taskbook.git"; + private static final String DUMMY_JAVA = "src/main/java/org/dummy/Dummy.java"; + + @Rule + public TemporaryFolder temp = new TemporaryFolder(); + @Rule + public LogTester logTester = new LogTester(); + private final GitBlameCommand blameCommand = new GitBlameCommand(); + + @Test + public void blame_collects_all_lines() throws IOException { + File projectDir = createNewTempFolder(); + javaUnzip("dummy-git.zip", projectDir); + File baseDir = new File(projectDir, "dummy-git"); + + List blame = blameCommand.blame(baseDir.toPath(), DUMMY_JAVA); + + Date revisionDate1 = DateUtils.parseDateTime("2012-07-17T16:12:48+0200"); + String revision1 = "6b3aab35a3ea32c1636fee56f996e677653c48ea"; + String author1 = "david@gageot.net"; + + // second commit, which has a commit date different than the author date + Date revisionDate2 = DateUtils.parseDateTime("2015-05-19T13:31:09+0200"); + String revision2 = "0d269c1acfb8e6d4d33f3c43041eb87e0df0f5e7"; + String author2 = "duarte.meneses@sonarsource.com"; + + List expectedBlame = new LinkedList<>(); + for (int i = 0; i < 25; i++) { + expectedBlame.add(new BlameLine().revision(revision1).date(revisionDate1).author(author1)); + } + for (int i = 0; i < 3; i++) { + expectedBlame.add(new BlameLine().revision(revision2).date(revisionDate2).author(author2)); + } + for (int i = 0; i < 1; i++) { + expectedBlame.add(new BlameLine().revision(revision1).date(revisionDate1).author(author1)); + } + + assertThat(blame).isEqualTo(expectedBlame); + } @Test - public void testBlame() throws IOException, InterruptedException { - String tmpDirectory = Files.createTempDirectory("tmpDirectory").toFile().getAbsolutePath(); - Path directory = Paths.get(tmpDirectory); - gitClone(directory, ORIGIN_URL); - List blameOutput = gitBlame(directory, "readme.md"); - assertThat(blameOutput.size()).isEqualTo(378); + public void modified_file_returns_no_blame() throws IOException { + File projectDir = createNewTempFolder(); + javaUnzip("dummy-git.zip", projectDir); + + Path baseDir = projectDir.toPath().resolve("dummy-git"); + + // Emulate a modification + Files.write(baseDir.resolve(DUMMY_JAVA), "modification and \n some new line".getBytes()); + + assertThat(blameCommand.blame(baseDir, DUMMY_JAVA)).isEmpty(); } @Test - public void initAndAddFile() throws IOException, InterruptedException { - String tmpDirectory = Files.createTempDirectory("tmpDirectory").toFile().getAbsolutePath(); - Path directory = Paths.get(tmpDirectory); - Files.createDirectories(directory); - gitInit(directory); - Files.write(directory.resolve("bar.c"), new byte[0]); - gitStage(directory); - gitCommit(directory, "Add bar.c"); + public void new_file_returns_no_blame() throws IOException { + File projectDir = createNewTempFolder(); + javaUnzip("dummy-git.zip", projectDir); + + File baseDir = new File(projectDir, "dummy-git"); + String relativePath2 = "src/main/java/org/dummy/Dummy2.java"; + + // Emulate a new file + FileUtils.copyFile(new File(baseDir, DUMMY_JAVA), new File(baseDir, relativePath2)); + + assertThat(blameCommand.blame(baseDir.toPath(), DUMMY_JAVA)).hasSize(29); + assertThat(blameCommand.blame(baseDir.toPath(), relativePath2)).isEmpty(); } @Test - public void cloneAndAddFile() throws IOException, InterruptedException { - String tmpDirectory = Files.createTempDirectory("tmpDirectory").toFile().getAbsolutePath(); - Path directory = Paths.get(tmpDirectory); - gitClone(directory, ORIGIN_URL); - Files.write(directory.resolve("bar.c"), new byte[0]); - gitStage(directory); - gitCommit(directory, "Add bar.c"); - // gitPush(directory); // don't push + public void symlink_doesnt_fail() throws IOException { + assumeTrue(!System2.INSTANCE.isOsWindows()); + File projectDir = temp.newFolder(); + javaUnzip("dummy-git.zip", projectDir); + + Path baseDir = projectDir.toPath().resolve("dummy-git"); + String relativePath2 = "src/main/java/org/dummy/Dummy2.java"; + + // Create symlink + Files.createSymbolicLink(baseDir.resolve(relativePath2), baseDir.resolve(DUMMY_JAVA)); + + blameCommand.blame(baseDir, DUMMY_JAVA); + blameCommand.blame(baseDir, relativePath2); + } + + @Test + public void git_should_be_detected() throws IOException { + Path baseDir = temp.newFolder().toPath(); + GitBlameCommand blameCommand = new GitBlameCommand(); + assertThat(blameCommand.isEnabled(baseDir)).isTrue(); + } + + @Test + public void git_should_not_be_detected() throws IOException { + Path baseDir = temp.newFolder().toPath(); + GitBlameCommand blameCommand = new GitBlameCommand("randomcmdthatwillneverbefound"); + assertThat(blameCommand.isEnabled(baseDir)).isFalse(); + } + + @Test + public void return_empty_if_command_fails() throws IOException { + Path baseDir = temp.newFolder().toPath(); + GitBlameCommand blameCommand = new GitBlameCommand("randomcmdthatwillneverbefound"); + assertThat(blameCommand.blame(baseDir, "file")).isEmpty(); + } + + @Test + public void blame_without_email_doesnt_fail() throws IOException, GitAPIException { + Path baseDir = temp.newFolder().toPath(); + Git git = createRepository(baseDir); + String filePath = "file.txt"; + createFile(filePath, "line", baseDir); + commitWithNoEamil(git, filePath); + + GitBlameCommand blameCommand = new GitBlameCommand(); + List blame = blameCommand.blame(baseDir, filePath); + assertThat(blame).hasSize(1); + BlameLine blameLine = blame.get(0); + assertThat(blameLine.author()).isNull(); + assertThat(blameLine.revision()).isNotNull(); + assertThat(blameLine.date()).isNotNull(); + } + + private void commitWithNoEamil(Git git, String path) throws GitAPIException { + git.add().addFilepattern(path).call(); + git.commit().setCommitter("joe", "").setMessage("msg").call(); + } + + private File createNewTempFolder() throws IOException { + //This is needed for Windows, otherwise the created File point to invalid (shortened by Windows) temp folder path + return temp.newFolder().toPath().toRealPath(LinkOption.NOFOLLOW_LINKS).toFile(); } -} \ No newline at end of file +} diff --git a/sonar-scanner-engine/src/test/java/org/sonar/scm/git/GitScmProviderTest.java b/sonar-scanner-engine/src/test/java/org/sonar/scm/git/GitScmProviderTest.java index bb626c5f01f..d7f5006e035 100644 --- a/sonar-scanner-engine/src/test/java/org/sonar/scm/git/GitScmProviderTest.java +++ b/sonar-scanner-engine/src/test/java/org/sonar/scm/git/GitScmProviderTest.java @@ -28,7 +28,6 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardOpenOption; import java.time.Instant; -import java.time.temporal.ChronoUnit; import java.util.Arrays; import java.util.Collections; import java.util.Date; @@ -73,6 +72,8 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import static org.sonar.api.utils.log.LoggerLevel.WARN; +import static org.sonar.scm.git.GitUtils.createFile; +import static org.sonar.scm.git.GitUtils.createRepository; import static org.sonar.scm.git.Utils.javaUnzip; public class GitScmProviderTest { @@ -136,8 +137,10 @@ public class GitScmProviderTest { @Test public void returnImplem() { - JGitBlameCommand jblameCommand = new JGitBlameCommand(new PathResolver(), analysisWarnings); - GitScmProvider gitScmProvider = new GitScmProvider(jblameCommand, analysisWarnings, gitIgnoreCommand, system2); + JGitBlameCommand jblameCommand = new JGitBlameCommand(); + GitBlameCommand nativeBlameCommand = new GitBlameCommand(); + CompositeBlameCommand compositeBlameCommand = new CompositeBlameCommand(analysisWarnings, new PathResolver(), jblameCommand, nativeBlameCommand); + GitScmProvider gitScmProvider = new GitScmProvider(compositeBlameCommand, analysisWarnings, gitIgnoreCommand, system2); assertThat(gitScmProvider.blameCommand()).isEqualTo(jblameCommand); } @@ -176,8 +179,8 @@ public class GitScmProviderTest { assertThat(newScmProvider().supports(baseDir)).isTrue(); } - private static JGitBlameCommand mockCommand() { - return mock(JGitBlameCommand.class); + private static CompositeBlameCommand mockCommand() { + return mock(CompositeBlameCommand.class); } @Test @@ -305,12 +308,6 @@ public class GitScmProviderTest { assertThat(changedLines.entrySet().iterator().next().getValue()).containsOnly(4, 5, 6); } - private Git createRepository(Path worktree) throws IOException { - Repository repo = FileRepositoryBuilder.create(worktree.resolve(".git").toFile()); - repo.create(); - return new Git(repo); - } - private void addSubmodule(Git mainGit, String submoduleName, String uriToSubmodule) throws GitAPIException { mainGit.submoduleAdd().setPath(submoduleName).setURI(uriToSubmodule).call(); mainGit.commit().setAuthor("joe", "joe@example.com").setMessage("adding submodule").call(); @@ -663,7 +660,7 @@ public class GitScmProviderTest { } private GitScmProvider newGitScmProvider() { - return new GitScmProvider(mock(JGitBlameCommand.class), analysisWarnings, gitIgnoreCommand, system2); + return new GitScmProvider(mock(CompositeBlameCommand.class), analysisWarnings, gitIgnoreCommand, system2); } @Test @@ -760,12 +757,6 @@ public class GitScmProviderTest { commit(git, relativePath); } - private void createFile(String relativePath, String content, Path worktree) throws IOException { - Path newFile = worktree.resolve(relativePath); - Files.createDirectories(newFile.getParent()); - Files.write(newFile, content.getBytes(), StandardOpenOption.CREATE); - } - private void addLineToFile(String relativePath, int lineNumber) throws IOException { Path filePath = worktree.resolve(relativePath); List lines = Files.readAllLines(filePath); diff --git a/sonar-scanner-engine/src/test/java/org/sonar/scm/git/GitScmSupportTest.java b/sonar-scanner-engine/src/test/java/org/sonar/scm/git/GitScmSupportTest.java index 41fe48f9d34..b3749f9e8e1 100644 --- a/sonar-scanner-engine/src/test/java/org/sonar/scm/git/GitScmSupportTest.java +++ b/sonar-scanner-engine/src/test/java/org/sonar/scm/git/GitScmSupportTest.java @@ -27,7 +27,7 @@ public class GitScmSupportTest { @Test public void getClasses() { - assertThat(GitScmSupport.getObjects()).hasSize(3); + assertThat(GitScmSupport.getObjects()).isNotEmpty(); } } diff --git a/sonar-scanner-engine/src/test/java/org/sonar/scm/git/GitUtils.java b/sonar-scanner-engine/src/test/java/org/sonar/scm/git/GitUtils.java new file mode 100644 index 00000000000..e47475ad22a --- /dev/null +++ b/sonar-scanner-engine/src/test/java/org/sonar/scm/git/GitUtils.java @@ -0,0 +1,44 @@ +/* + * SonarQube + * Copyright (C) 2009-2022 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.scm.git; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.storage.file.FileRepositoryBuilder; + +public class GitUtils { + public static Git createRepository(Path worktree) throws IOException { + Repository repo = FileRepositoryBuilder.create(worktree.resolve(".git").toFile()); + repo.create(); + return new Git(repo); + } + + public static void createFile(String relativePath, String content, Path worktree) throws IOException { + Path newFile = worktree.resolve(relativePath); + Files.createDirectories(newFile.getParent()); + Files.write(newFile, content.getBytes(), StandardOpenOption.CREATE); + } + + +} diff --git a/sonar-scanner-engine/src/test/java/org/sonar/scm/git/JGitBlameCommandTest.java b/sonar-scanner-engine/src/test/java/org/sonar/scm/git/JGitBlameCommandTest.java index 6ceb80baf7f..2f51e80b77d 100644 --- a/sonar-scanner-engine/src/test/java/org/sonar/scm/git/JGitBlameCommandTest.java +++ b/sonar-scanner-engine/src/test/java/org/sonar/scm/git/JGitBlameCommandTest.java @@ -24,39 +24,22 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.Path; -import java.util.Arrays; -import java.util.Collections; import java.util.Date; -import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; -import java.util.Map; import org.apache.commons.io.FileUtils; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.lib.Repository; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; -import org.sonar.api.batch.fs.InputFile; -import org.sonar.api.batch.fs.internal.DefaultFileSystem; -import org.sonar.api.batch.fs.internal.DefaultInputFile; -import org.sonar.api.batch.fs.internal.TestInputFileBuilder; -import org.sonar.api.batch.scm.BlameCommand.BlameInput; -import org.sonar.api.batch.scm.BlameCommand.BlameOutput; import org.sonar.api.batch.scm.BlameLine; -import org.sonar.api.notifications.AnalysisWarnings; -import org.sonar.api.scan.filesystem.PathResolver; import org.sonar.api.utils.DateUtils; -import org.sonar.api.utils.MessageException; import org.sonar.api.utils.System2; import org.sonar.api.utils.log.LogTester; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.Assume.assumeTrue; -import static org.mockito.ArgumentMatchers.startsWith; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; import static org.sonar.scm.git.Utils.javaUnzip; public class JGitBlameCommandTest { @@ -69,277 +52,99 @@ public class JGitBlameCommandTest { @Rule public LogTester logTester = new LogTester(); - private final BlameInput input = mock(BlameInput.class); + private final JGitBlameCommand jGitBlameCommand = new JGitBlameCommand(); @Test - public void testBlame() throws IOException { + public void blame_returns_all_lines() throws IOException { File projectDir = createNewTempFolder(); javaUnzip("dummy-git.zip", projectDir); - JGitBlameCommand jGitBlameCommand = newJGitBlameCommand(); - File baseDir = new File(projectDir, "dummy-git"); - DefaultFileSystem fs = new DefaultFileSystem(baseDir); - when(input.fileSystem()).thenReturn(fs); - DefaultInputFile inputFile = new TestInputFileBuilder("foo", DUMMY_JAVA) - .setModuleBaseDir(baseDir.toPath()) - .build(); - fs.add(inputFile); - - BlameOutput blameResult = mock(BlameOutput.class); - when(input.filesToBlame()).thenReturn(Arrays.asList(inputFile)); - jGitBlameCommand.blame(input, blameResult); - - Date revisionDate1 = DateUtils.parseDateTime("2012-07-17T16:12:48+0200"); - String revision1 = "6b3aab35a3ea32c1636fee56f996e677653c48ea"; - String author1 = "david@gageot.net"; - - // second commit, which has a commit date different than the author date - Date revisionDate2 = DateUtils.parseDateTime("2015-05-19T13:31:09+0200"); - String revision2 = "0d269c1acfb8e6d4d33f3c43041eb87e0df0f5e7"; - String author2 = "duarte.meneses@sonarsource.com"; - List expectedBlame = new LinkedList<>(); - for (int i = 0; i < 25; i++) { - expectedBlame.add(new BlameLine().revision(revision1).date(revisionDate1).author(author1)); + try (Git git = loadRepository(baseDir.toPath())) { + List blameLines = jGitBlameCommand.blame(git, DUMMY_JAVA); + + Date revisionDate1 = DateUtils.parseDateTime("2012-07-17T16:12:48+0200"); + String revision1 = "6b3aab35a3ea32c1636fee56f996e677653c48ea"; + String author1 = "david@gageot.net"; + + // second commit, which has a commit date different than the author date + Date revisionDate2 = DateUtils.parseDateTime("2015-05-19T13:31:09+0200"); + String revision2 = "0d269c1acfb8e6d4d33f3c43041eb87e0df0f5e7"; + String author2 = "duarte.meneses@sonarsource.com"; + + List expectedBlame = new LinkedList<>(); + for (int i = 0; i < 25; i++) { + expectedBlame.add(new BlameLine().revision(revision1).date(revisionDate1).author(author1)); + } + for (int i = 0; i < 3; i++) { + expectedBlame.add(new BlameLine().revision(revision2).date(revisionDate2).author(author2)); + } + for (int i = 0; i < 1; i++) { + expectedBlame.add(new BlameLine().revision(revision1).date(revisionDate1).author(author1)); + } + + assertThat(blameLines).isEqualTo(expectedBlame); } - for (int i = 0; i < 3; i++) { - expectedBlame.add(new BlameLine().revision(revision2).date(revisionDate2).author(author2)); - } - for (int i = 0; i < 1; i++) { - expectedBlame.add(new BlameLine().revision(revision1).date(revisionDate1).author(author1)); - } - - verify(blameResult).blameResult(inputFile, expectedBlame); - } - - @Test - public void properFailureIfNotAGitProject() throws IOException { - File projectDir = createNewTempFolder(); - javaUnzip("dummy-git.zip", projectDir); - - JGitBlameCommand jGitBlameCommand = newJGitBlameCommand(); - - File baseDir = new File(projectDir, "dummy-git"); - - // Delete .git - FileUtils.forceDelete(new File(baseDir, ".git")); - - DefaultFileSystem fs = new DefaultFileSystem(baseDir); - when(input.fileSystem()).thenReturn(fs); - DefaultInputFile inputFile = new TestInputFileBuilder("foo", DUMMY_JAVA).build(); - fs.add(inputFile); - - BlameOutput blameResult = mock(BlameOutput.class); - when(input.filesToBlame()).thenReturn(Arrays.asList(inputFile)); - - assertThatThrownBy(() -> jGitBlameCommand.blame(input, blameResult)) - .isInstanceOf(MessageException.class) - .hasMessageContaining("Not inside a Git work tree: "); - } - - @Test - public void testBlameOnNestedModule() throws IOException { - File projectDir = createNewTempFolder(); - javaUnzip("dummy-git-nested.zip", projectDir); - - JGitBlameCommand jGitBlameCommand = newJGitBlameCommand(); - - File baseDir = new File(projectDir, "dummy-git-nested/dummy-project"); - DefaultFileSystem fs = new DefaultFileSystem(baseDir); - when(input.fileSystem()).thenReturn(fs); - DefaultInputFile inputFile = new TestInputFileBuilder("foo", DUMMY_JAVA) - .setModuleBaseDir(baseDir.toPath()) - .build(); - fs.add(inputFile); - - BlameOutput blameResult = mock(BlameOutput.class); - when(input.filesToBlame()).thenReturn(Arrays.asList(inputFile)); - jGitBlameCommand.blame(input, blameResult); - - Date revisionDate = DateUtils.parseDateTime("2012-07-17T16:12:48+0200"); - String revision = "6b3aab35a3ea32c1636fee56f996e677653c48ea"; - String author = "david@gageot.net"; - verify(blameResult).blameResult(inputFile, - Arrays.asList( - new BlameLine().revision(revision).date(revisionDate).author(author), - new BlameLine().revision(revision).date(revisionDate).author(author), - new BlameLine().revision(revision).date(revisionDate).author(author), - new BlameLine().revision(revision).date(revisionDate).author(author), - new BlameLine().revision(revision).date(revisionDate).author(author), - new BlameLine().revision(revision).date(revisionDate).author(author), - new BlameLine().revision(revision).date(revisionDate).author(author), - new BlameLine().revision(revision).date(revisionDate).author(author), - new BlameLine().revision(revision).date(revisionDate).author(author), - new BlameLine().revision(revision).date(revisionDate).author(author), - new BlameLine().revision(revision).date(revisionDate).author(author), - new BlameLine().revision(revision).date(revisionDate).author(author), - new BlameLine().revision(revision).date(revisionDate).author(author), - new BlameLine().revision(revision).date(revisionDate).author(author), - new BlameLine().revision(revision).date(revisionDate).author(author), - new BlameLine().revision(revision).date(revisionDate).author(author), - new BlameLine().revision(revision).date(revisionDate).author(author), - new BlameLine().revision(revision).date(revisionDate).author(author), - new BlameLine().revision(revision).date(revisionDate).author(author), - new BlameLine().revision(revision).date(revisionDate).author(author), - new BlameLine().revision(revision).date(revisionDate).author(author), - new BlameLine().revision(revision).date(revisionDate).author(author), - new BlameLine().revision(revision).date(revisionDate).author(author), - new BlameLine().revision(revision).date(revisionDate).author(author), - new BlameLine().revision(revision).date(revisionDate).author(author), - new BlameLine().revision(revision).date(revisionDate).author(author))); } @Test - public void dontFailOnModifiedFile() throws IOException { + public void modified_file_returns_no_blame() throws IOException { File projectDir = createNewTempFolder(); javaUnzip("dummy-git.zip", projectDir); - JGitBlameCommand jGitBlameCommand = newJGitBlameCommand(); - - File baseDir = new File(projectDir, "dummy-git"); - DefaultFileSystem fs = new DefaultFileSystem(baseDir); - when(input.fileSystem()).thenReturn(fs); - String relativePath = DUMMY_JAVA; - DefaultInputFile inputFile = new TestInputFileBuilder("foo", relativePath).build(); - fs.add(inputFile); + Path baseDir = projectDir.toPath().resolve("dummy-git"); // Emulate a modification - Files.write(baseDir.toPath().resolve(relativePath), "modification and \n some new line".getBytes()); - - BlameOutput blameResult = mock(BlameOutput.class); + Files.write(baseDir.resolve(DUMMY_JAVA), "modification and \n some new line".getBytes()); - when(input.filesToBlame()).thenReturn(Arrays.asList(inputFile)); - jGitBlameCommand.blame(input, blameResult); + try (Git git = loadRepository(baseDir)) { + assertThat(jGitBlameCommand.blame(git, DUMMY_JAVA)).isEmpty(); + } } @Test - public void dontFailOnNewFile() throws IOException { + public void new_file_returns_no_blame() throws IOException { File projectDir = createNewTempFolder(); javaUnzip("dummy-git.zip", projectDir); - JGitBlameCommand jGitBlameCommand = newJGitBlameCommand(); - File baseDir = new File(projectDir, "dummy-git"); - DefaultFileSystem fs = new DefaultFileSystem(baseDir); - when(input.fileSystem()).thenReturn(fs); - String relativePath = DUMMY_JAVA; String relativePath2 = "src/main/java/org/dummy/Dummy2.java"; - DefaultInputFile inputFile = new TestInputFileBuilder("foo", relativePath).build(); - fs.add(inputFile); - DefaultInputFile inputFile2 = new TestInputFileBuilder("foo", relativePath2).build(); - fs.add(inputFile2); // Emulate a new file - FileUtils.copyFile(new File(baseDir, relativePath), new File(baseDir, relativePath2)); + FileUtils.copyFile(new File(baseDir, DUMMY_JAVA), new File(baseDir, relativePath2)); - BlameOutput blameResult = mock(BlameOutput.class); - - when(input.filesToBlame()).thenReturn(Arrays.asList(inputFile, inputFile2)); - jGitBlameCommand.blame(input, blameResult); + try (Git git = loadRepository(baseDir.toPath())) { + assertThat(jGitBlameCommand.blame(git, DUMMY_JAVA)).hasSize(29); + assertThat(jGitBlameCommand.blame(git, relativePath2)).isEmpty(); + } } @Test - public void dontFailOnSymlink() throws IOException { + public void symlink_doesnt_fail() throws IOException { assumeTrue(!System2.INSTANCE.isOsWindows()); File projectDir = temp.newFolder(); javaUnzip("dummy-git.zip", projectDir); - JGitBlameCommand jGitBlameCommand = newJGitBlameCommand(); - - File baseDir = new File(projectDir, "dummy-git"); - DefaultFileSystem fs = new DefaultFileSystem(baseDir); - when(input.fileSystem()).thenReturn(fs); - String relativePath = DUMMY_JAVA; + Path baseDir = projectDir.toPath().resolve("dummy-git"); String relativePath2 = "src/main/java/org/dummy/Dummy2.java"; - DefaultInputFile inputFile = new TestInputFileBuilder("foo", relativePath) - .setModuleBaseDir(baseDir.toPath()) - .build(); - fs.add(inputFile); - DefaultInputFile inputFile2 = new TestInputFileBuilder("foo", relativePath2) - .setModuleBaseDir(baseDir.toPath()) - .build(); - fs.add(inputFile2); // Create symlink - Files.createSymbolicLink(inputFile2.file().toPath(), inputFile.file().toPath()); + Files.createSymbolicLink(baseDir.resolve(relativePath2), baseDir.resolve(DUMMY_JAVA)); - BlameOutput blameResult = mock(BlameOutput.class); - - when(input.filesToBlame()).thenReturn(Arrays.asList(inputFile, inputFile2)); - jGitBlameCommand.blame(input, blameResult); + try (Git git = loadRepository(baseDir)) { + jGitBlameCommand.blame(git, DUMMY_JAVA); + jGitBlameCommand.blame(git, relativePath2); + } } - @Test - public void return_early_when_shallow_clone_detected() throws IOException { - File projectDir = createNewTempFolder(); - javaUnzip("shallow-git.zip", projectDir); - - File baseDir = new File(projectDir, "shallow-git"); - - DefaultFileSystem fs = new DefaultFileSystem(baseDir); - when(input.fileSystem()).thenReturn(fs); - - DefaultInputFile inputFile = new TestInputFileBuilder("foo", DUMMY_JAVA).build(); - when(input.filesToBlame()).thenReturn(Collections.singleton(inputFile)); - - // register warning with default wrapper - AnalysisWarnings analysisWarnings = mock(AnalysisWarnings.class); - JGitBlameCommand jGitBlameCommand = new JGitBlameCommand(new PathResolver(), analysisWarnings); - BlameOutput output = mock(BlameOutput.class); - jGitBlameCommand.blame(input, output); - - assertThat(logTester.logs()).first() - .matches(s -> s.contains("Shallow clone detected, no blame information will be provided.")); - verifyNoInteractions(output); - - verify(analysisWarnings).addUnique(startsWith("Shallow clone detected")); - } - - @Test - public void return_early_when_clone_with_reference_detected() throws IOException { - File projectDir = createNewTempFolder(); - javaUnzip("dummy-git-reference-clone.zip", projectDir); - - Path baseDir = projectDir.toPath().resolve("dummy-git2"); - - DefaultFileSystem fs = new DefaultFileSystem(baseDir); - when(input.fileSystem()).thenReturn(fs); - - DefaultInputFile inputFile = new TestInputFileBuilder("foo", DUMMY_JAVA).setModuleBaseDir(baseDir).build(); - when(input.filesToBlame()).thenReturn(Collections.singleton(inputFile)); - - // register warning - AnalysisWarnings analysisWarnings = mock(AnalysisWarnings.class); - JGitBlameCommand jGitBlameCommand = new JGitBlameCommand(new PathResolver(), analysisWarnings); - TestBlameOutput output = new TestBlameOutput(); - jGitBlameCommand.blame(input, output); - - assertThat(logTester.logs()).first() - .matches(s -> s.contains("This git repository references another local repository which is not well supported")); - - // contains commits referenced from the old clone and commits in the new clone - assertThat(output.blame).containsKey(inputFile); - assertThat(output.blame.get(inputFile).stream().map(BlameLine::revision)) - .containsOnly("6b3aab35a3ea32c1636fee56f996e677653c48ea", "843c7c30d7ebd9a479e8f1daead91036c75cbc4e", "0d269c1acfb8e6d4d33f3c43041eb87e0df0f5e7"); - verifyNoInteractions(analysisWarnings); + private Git loadRepository(Path dir) { + Repository repo = JGitUtils.buildRepository(dir); + return Git.wrap(repo); } private File createNewTempFolder() throws IOException { //This is needed for Windows, otherwise the created File point to invalid (shortened by Windows) temp folder path return temp.newFolder().toPath().toRealPath(LinkOption.NOFOLLOW_LINKS).toFile(); } - - private JGitBlameCommand newJGitBlameCommand() { - return new JGitBlameCommand(new PathResolver(), mock(AnalysisWarnings.class)); - } - - private static class TestBlameOutput implements BlameOutput { - private Map> blame = new LinkedHashMap<>(); - - @Override public void blameResult(InputFile inputFile, List list) { - blame.put(inputFile, list); - } - } - } -- cgit v1.2.3