aboutsummaryrefslogtreecommitdiffstats
path: root/sonar-scanner-engine/src/main/java/org/sonar/scm/git/GitBlameCommand.java
blob: 10733386574877cac7227e7fccaeff587f1c2a03 (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
/*
 * 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.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 org.sonar.api.batch.scm.BlameLine;
import org.sonar.api.utils.System2;
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 {
  private static final Logger LOG = Loggers.get(GitBlameCommand.class);
  private static final Pattern EMAIL_PATTERN = Pattern.compile("<(\\S*?)>");
  private static final String COMMITTER_TIME = "committer-time ";
  private static final String COMMITTER_MAIL = "committer-mail ";

  private static final String DEFAULT_GIT_COMMAND = "git";
  private static final String BLAME_COMMAND = "blame";
  private static final String BLAME_LINE_PORCELAIN_FLAG = "--line-porcelain";
  private static final String IGNORE_WHITESPACES = "-w";

  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");
    } 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, BLAME_COMMAND, BLAME_LINE_PORCELAIN_FLAG, IGNORE_WHITESPACES, 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 committerMail = 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(COMMITTER_MAIL)) {
        Matcher matcher = EMAIL_PATTERN.matcher(line);
        if (!matcher.find(COMMITTER_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 UncommittedLineException();
        }
      }
    }

    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;
  }

  private static class UncommittedLineException extends RuntimeException {

  }
}