aboutsummaryrefslogtreecommitdiffstats
path: root/sonar-scanner-engine/src/main/java/org/sonar/scm/git/GitBlameCommand.java
blob: 9469ec15c3807a26609842cf69c22deca8df1ed6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
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 GitBlameCommand {
  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(GitBlameCommand.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 GitBlameCommand(System2 system, ProcessWrapperFactory processWrapperFactory) {
    this.system = system;
    this.processWrapperFactory = processWrapperFactory;
  }

  GitBlameCommand(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 {

  }
}