aboutsummaryrefslogtreecommitdiffstats
path: root/sonar-scanner-engine/src/main/java/org/sonar/scm/git/NativeGitBlameCommand.java
diff options
context:
space:
mode:
Diffstat (limited to 'sonar-scanner-engine/src/main/java/org/sonar/scm/git/NativeGitBlameCommand.java')
-rw-r--r--sonar-scanner-engine/src/main/java/org/sonar/scm/git/NativeGitBlameCommand.java213
1 files changed, 213 insertions, 0 deletions
diff --git a/sonar-scanner-engine/src/main/java/org/sonar/scm/git/NativeGitBlameCommand.java b/sonar-scanner-engine/src/main/java/org/sonar/scm/git/NativeGitBlameCommand.java
new file mode 100644
index 00000000000..0dd2e1620ef
--- /dev/null
+++ b/sonar-scanner-engine/src/main/java/org/sonar/scm/git/NativeGitBlameCommand.java
@@ -0,0 +1,213 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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.Path;
+import java.time.Instant;
+import java.util.Date;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import org.apache.commons.lang.math.NumberUtils;
+import org.sonar.api.batch.scm.BlameLine;
+import org.sonar.api.utils.System2;
+import org.sonar.api.utils.Version;
+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.Collections.emptyList;
+import static org.sonar.api.utils.Preconditions.checkState;
+
+public class NativeGitBlameCommand {
+ protected static final String BLAME_COMMAND = "blame";
+ protected static final String GIT_DIR_FLAG = "--git-dir";
+ protected static final String GIT_DIR_ARGUMENT = "%s/.git";
+ protected static final String GIT_DIR_FORCE_FLAG = "-C";
+
+ private static final Logger LOG = Loggers.get(NativeGitBlameCommand.class);
+ private static final Pattern EMAIL_PATTERN = Pattern.compile("<(.*?)>");
+ private static final String COMMITTER_TIME = "committer-time ";
+ private static final String AUTHOR_MAIL = "author-mail ";
+
+ private static final String MINIMUM_REQUIRED_GIT_VERSION = "2.24.0";
+ private static final String DEFAULT_GIT_COMMAND = "git";
+ private static final String BLAME_LINE_PORCELAIN_FLAG = "--line-porcelain";
+ private static final String END_OF_OPTIONS_FLAG = "--end-of-options";
+ private static final String IGNORE_WHITESPACES = "-w";
+
+ private static final Pattern whitespaceRegex = Pattern.compile("\\s+");
+ private static final Pattern semanticVersionDelimiter = Pattern.compile("\\.");
+
+ private final System2 system;
+ private final ProcessWrapperFactory processWrapperFactory;
+ private String gitCommand;
+
+ @Autowired
+ public NativeGitBlameCommand(System2 system, ProcessWrapperFactory processWrapperFactory) {
+ this.system = system;
+ this.processWrapperFactory = processWrapperFactory;
+ }
+
+ NativeGitBlameCommand(String gitCommand, System2 system, ProcessWrapperFactory processWrapperFactory) {
+ this.gitCommand = gitCommand;
+ this.system = system;
+ this.processWrapperFactory = processWrapperFactory;
+ }
+
+ /**
+ * This method must be executed before org.sonar.scm.git.GitBlameCommand#blame
+ *
+ * @return true, if native git is installed
+ */
+ public boolean checkIfEnabled() {
+ try {
+ this.gitCommand = locateDefaultGit();
+ MutableString stdOut = new MutableString();
+ this.processWrapperFactory.create(null, l -> stdOut.string = l, gitCommand, "--version").execute();
+ return stdOut.string != null && stdOut.string.startsWith("git version") && isCompatibleGitVersion(stdOut.string);
+ } catch (Exception e) {
+ LOG.debug("Failed to find git native client", e);
+ return false;
+ }
+ }
+
+ private String locateDefaultGit() throws IOException {
+ if (this.gitCommand != null) {
+ return this.gitCommand;
+ }
+ // if not set fall back to defaults
+ if (system.isOsWindows()) {
+ return locateGitOnWindows();
+ }
+ return DEFAULT_GIT_COMMAND;
+ }
+
+ private String locateGitOnWindows() throws IOException {
+ // Windows will search current directory in addition to the PATH variable, which is unsecure.
+ // To avoid it we use where.exe to find git binary only in PATH.
+ LOG.debug("Looking for git command in the PATH using where.exe (Windows)");
+ List<String> whereCommandResult = new LinkedList<>();
+ this.processWrapperFactory.create(null, whereCommandResult::add, "C:\\Windows\\System32\\where.exe", "$PATH:git.exe")
+ .execute();
+
+ if (!whereCommandResult.isEmpty()) {
+ String out = whereCommandResult.get(0).trim();
+ LOG.debug("Found git.exe at {}", out);
+ return out;
+ }
+ throw new IllegalStateException("git.exe not found in PATH. PATH value was: " + system.property("PATH"));
+ }
+
+ public List<BlameLine> blame(Path baseDir, String fileName) throws Exception {
+ BlameOutputProcessor outputProcessor = new BlameOutputProcessor();
+ try {
+ this.processWrapperFactory.create(
+ baseDir,
+ outputProcessor::process,
+ gitCommand,
+ GIT_DIR_FLAG, String.format(GIT_DIR_ARGUMENT, baseDir), GIT_DIR_FORCE_FLAG, baseDir.toString(),
+ BLAME_COMMAND,
+ BLAME_LINE_PORCELAIN_FLAG, IGNORE_WHITESPACES, END_OF_OPTIONS_FLAG, fileName)
+ .execute();
+ } catch (UncommittedLineException e) {
+ LOG.debug("Unable to blame file '{}' - it has uncommitted changes", fileName);
+ return emptyList();
+ }
+ return outputProcessor.getBlameLines();
+ }
+
+ private static class BlameOutputProcessor {
+ private final List<BlameLine> blameLines = new LinkedList<>();
+ private String sha1 = null;
+ private String committerTime = null;
+ private String authorMail = null;
+
+ public List<BlameLine> getBlameLines() {
+ return blameLines;
+ }
+
+ public void process(String line) {
+ if (sha1 == null) {
+ sha1 = line.split(" ")[0];
+ } else if (line.startsWith("\t")) {
+ saveEntry();
+ } else if (line.startsWith(COMMITTER_TIME)) {
+ committerTime = line.substring(COMMITTER_TIME.length());
+ } else if (line.startsWith(AUTHOR_MAIL)) {
+ Matcher matcher = EMAIL_PATTERN.matcher(line);
+ if (matcher.find(AUTHOR_MAIL.length())) {
+ authorMail = matcher.group(1);
+ }
+ if (authorMail.equals("not.committed.yet")) {
+ throw new UncommittedLineException();
+ }
+ }
+ }
+
+ private void saveEntry() {
+ checkState(authorMail != null, "Did not find an author 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(authorMail)
+ .date(Date.from(Instant.ofEpochSecond(Long.parseLong(committerTime)))));
+ } catch (NumberFormatException e) {
+ throw new IllegalStateException("Invalid committer time found: " + committerTime);
+ }
+ authorMail = null;
+ sha1 = null;
+ committerTime = null;
+ }
+ }
+
+ private static boolean isCompatibleGitVersion(String gitVersionCommandOutput) {
+ // Due to the danger of argument injection on git blame the use of `--end-of-options` flag is necessary
+ // The flag is available only on git versions >= 2.24.0
+ String gitVersion = whitespaceRegex
+ .splitAsStream(gitVersionCommandOutput)
+ .skip(2)
+ .findFirst()
+ .orElse("");
+
+ String formattedGitVersion = formatGitSemanticVersion(gitVersion);
+ return Version.parse(formattedGitVersion).isGreaterThanOrEqual(Version.parse(MINIMUM_REQUIRED_GIT_VERSION));
+ }
+
+ private static String formatGitSemanticVersion(String version) {
+ return semanticVersionDelimiter
+ .splitAsStream(version)
+ .takeWhile(NumberUtils::isNumber)
+ .collect(Collectors.joining("."));
+ }
+
+ private static class MutableString {
+ String string;
+ }
+
+ private static class UncommittedLineException extends RuntimeException {
+
+ }
+}