All Downloads are FREE. Search and download functionalities are using the official Maven repository.

pl.project13.core.NativeGitProvider Maven / Gradle / Ivy

The newest version!
/*
 * This file is part of git-commit-id-plugin-core by Konrad 'ktoso' Malawski 
 *
 * git-commit-id-plugin-core 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.
 *
 * git-commit-id-plugin-core 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 General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with git-commit-id-plugin-core.  If not, see .
 */

package pl.project13.core;

import static java.lang.String.format;

import pl.project13.core.git.GitDescribeConfig;
import pl.project13.core.log.LogInterface;

import javax.annotation.Nonnull;

import java.io.*;
import java.text.SimpleDateFormat;
import java.util.Optional;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class NativeGitProvider extends GitDataProvider {

  private transient ProcessRunner runner;

  final File dotGitDirectory;

  final long nativeGitTimeoutInMs;

  final File canonical;

  @Nonnull
  public static NativeGitProvider on(@Nonnull File dotGitDirectory, long nativeGitTimeoutInMs, @Nonnull LogInterface log) {
    return new NativeGitProvider(dotGitDirectory, nativeGitTimeoutInMs, log);
  }

  NativeGitProvider(@Nonnull File dotGitDirectory, long nativeGitTimeoutInMs, @Nonnull LogInterface log) {
    super(log);
    this.dotGitDirectory = dotGitDirectory;
    this.nativeGitTimeoutInMs = nativeGitTimeoutInMs;
    try {
      this.canonical = dotGitDirectory.getCanonicalFile();
    } catch (IOException ex) {
      throw new RuntimeException(new GitCommitIdExecutionException("Passed a invalid directory, not a GIT repository: " + dotGitDirectory, ex));
    }
  }

  @Override
  public void init() throws GitCommitIdExecutionException {
    // noop ...
  }

  @Override
  public String getBuildAuthorName() throws GitCommitIdExecutionException {
    try {
      return runGitCommand(canonical, nativeGitTimeoutInMs, "config --get user.name");
    } catch (NativeCommandException e) {
      if (e.getExitCode() == 1) { // No config file found
        return "";
      }
      throw new RuntimeException(e);
    }
  }

  @Override
  public String getBuildAuthorEmail() throws GitCommitIdExecutionException {
    try {
      return runGitCommand(canonical, nativeGitTimeoutInMs, "config --get user.email");
    } catch (NativeCommandException e) {
      if (e.getExitCode() == 1) { // No config file found
        return "";
      }
      throw new RuntimeException(e);
    }
  }

  @Override
  public void prepareGitToExtractMoreDetailedRepoInformation() throws GitCommitIdExecutionException {
  }

  @Override
  public String getBranchName() throws GitCommitIdExecutionException {
    if (evalCommitIsNotHead()) {
      // git branch --points-at $evaluateOnCommit
      return getBranchForCommitish(canonical);
    } else {
      // git symbolic-ref --short HEAD
      return getBranchForHead(canonical);
    }
  }

  private boolean evalCommitIsNotHead() {
    return (evaluateOnCommit != null) && !evaluateOnCommit.equals("HEAD");
  }

  private String getBranchForHead(File canonical) throws GitCommitIdExecutionException {
    String branch;
    try {
      branch = runGitCommand(canonical, nativeGitTimeoutInMs, "symbolic-ref --short " + evaluateOnCommit);
    } catch (NativeCommandException e) {
      // it seems that git repo is in 'DETACHED HEAD'-State, using Commit-Id as Branch
      String err = e.getStderr();
      if (err != null) {
        boolean noSymbolicRef = err.contains("ref " + evaluateOnCommit + " is not a symbolic ref");
        boolean noSuchRef = err.contains("No such ref: " + evaluateOnCommit);
        if (noSymbolicRef || noSuchRef) {
          branch = getCommitId();
        } else {
          throw new GitCommitIdExecutionException(e);
        }
      } else {
        throw new GitCommitIdExecutionException(e);
      }
    }
    return branch;
  }

  private String getBranchForCommitish(File canonical) throws GitCommitIdExecutionException {
    String branch = runQuietGitCommand(canonical, nativeGitTimeoutInMs, "branch --points-at " + evaluateOnCommit);
    if (branch != null && !branch.isEmpty()) {
      // multiple branches could point to the same commit - return them all...
      branch = Stream.of(branch.split("\n"))
              .map(s -> s.replaceAll("[\\* ]+", ""))
              // --points-at returns detached HEAD as branch, we don't like that
              .filter(s -> !s.startsWith("(HEAD detached at"))
              .collect(Collectors.joining(","));
    } else {
      // it seems that nothing is pointing to the commit, using Commit-Id as Branch
      branch = getCommitId();
    }
    return branch;
  }

  @Override
  public String getGitDescribe() throws GitCommitIdExecutionException {
    final String argumentsForGitDescribe = getArgumentsForGitDescribe(gitDescribe);
    return runQuietGitCommand(canonical, nativeGitTimeoutInMs, "describe" + argumentsForGitDescribe);
  }

  private String getArgumentsForGitDescribe(GitDescribeConfig describeConfig) {
    if (describeConfig == null) {
      return "";
    }

    StringBuilder argumentsForGitDescribe = new StringBuilder();
    boolean hasCommitish = evalCommitIsNotHead();
    if (hasCommitish) {
      argumentsForGitDescribe.append(" " + evaluateOnCommit);
    }

    if (describeConfig.isAlways()) {
      argumentsForGitDescribe.append(" --always");
    }

    final String dirtyMark = describeConfig.getDirty();
    if ((dirtyMark != null) && !dirtyMark.isEmpty()) {
      // we can either have evaluateOnCommit or --dirty flag set
      if (hasCommitish) {
        log.warn("You might use strange arguments since it's unfortunately not supported to have evaluateOnCommit and the --dirty flag for the describe command set at the same time");
      } else {
        argumentsForGitDescribe.append(" --dirty=").append(dirtyMark);
      }
    }

    final String matchOption = describeConfig.getMatch();
    if (matchOption != null && !matchOption.isEmpty()) {
      argumentsForGitDescribe.append(" --match=").append(matchOption);
    }

    argumentsForGitDescribe.append(" --abbrev=").append(describeConfig.getAbbrev());

    if (describeConfig.getTags()) {
      argumentsForGitDescribe.append(" --tags");
    }

    if (describeConfig.getForceLongFormat()) {
      argumentsForGitDescribe.append(" --long");
    }
    return argumentsForGitDescribe.toString();
  }

  @Override
  public String getCommitId() throws GitCommitIdExecutionException {
    boolean evaluateOnCommitIsSet = evalCommitIsNotHead();
    if (evaluateOnCommitIsSet) {
      // if evaluateOnCommit represents a tag we need to perform the rev-parse on the actual commit reference
      // in case evaluateOnCommit is not a reference rev-list will just return the argument given
      // and thus it's always safe(r) to unwrap it
      // however when evaluateOnCommit is not set we don't want to waste calls to the native binary
      String actualCommitId = runQuietGitCommand(canonical, nativeGitTimeoutInMs, "rev-list -n 1 " + evaluateOnCommit);
      return runQuietGitCommand(canonical, nativeGitTimeoutInMs, "rev-parse " + actualCommitId);
    } else {
      return runQuietGitCommand(canonical, nativeGitTimeoutInMs, "rev-parse HEAD");
    }
  }

  @Override
  public String getAbbrevCommitId() throws GitCommitIdExecutionException {
    // we could run: tryToRunGitCommand(canonical, "rev-parse --short="+abbrevLength+" HEAD");
    // but minimum length for --short is 4, our abbrevLength could be 2
    String commitId = getCommitId();
    String abbrevCommitId = "";

    if (commitId != null && !commitId.isEmpty()) {
      abbrevCommitId = commitId.substring(0, abbrevLength);
    }

    return abbrevCommitId;
  }

  @Override
  public boolean isDirty() throws GitCommitIdExecutionException {
    return !tryCheckEmptyRunGitCommand(canonical, nativeGitTimeoutInMs, "status -s");
  }

  @Override
  public String getCommitAuthorName() throws GitCommitIdExecutionException {
    return runQuietGitCommand(canonical, nativeGitTimeoutInMs, "log -1 --pretty=format:%an --no-show-signature " + evaluateOnCommit);
  }

  @Override
  public String getCommitAuthorEmail() throws GitCommitIdExecutionException {
    return runQuietGitCommand(canonical, nativeGitTimeoutInMs, "log -1 --pretty=format:%ae --no-show-signature " + evaluateOnCommit);
  }

  @Override
  public String getCommitMessageFull() throws GitCommitIdExecutionException {
    return runQuietGitCommand(canonical, nativeGitTimeoutInMs, "log -1 --pretty=format:%B --no-show-signature " + evaluateOnCommit);
  }

  @Override
  public String getCommitMessageShort() throws GitCommitIdExecutionException {
    return runQuietGitCommand(canonical, nativeGitTimeoutInMs, "log -1 --pretty=format:%s --no-show-signature " + evaluateOnCommit);
  }

  @Override
  public String getCommitTime() throws GitCommitIdExecutionException {
    String value =  runQuietGitCommand(canonical, nativeGitTimeoutInMs, "log -1 --pretty=format:%ct --no-show-signature " + evaluateOnCommit);
    SimpleDateFormat smf = getSimpleDateFormatWithTimeZone();
    return smf.format(Long.parseLong(value) * 1000L);
  }

  @Override
  public String getCommitAuthorTime() throws GitCommitIdExecutionException {
    String value =  runQuietGitCommand(canonical, nativeGitTimeoutInMs, "log -1 --pretty=format:%at --no-show-signature " + evaluateOnCommit);
    SimpleDateFormat smf = getSimpleDateFormatWithTimeZone();
    return smf.format(Long.parseLong(value) * 1000L);
  }

  @Override
  public String getCommitCommitterTime() throws GitCommitIdExecutionException {
    String value =  runQuietGitCommand(canonical, nativeGitTimeoutInMs, "log -1 --pretty=format:%ct --no-show-signature " + evaluateOnCommit);
    SimpleDateFormat smf = getSimpleDateFormatWithTimeZone();
    return smf.format(Long.parseLong(value) * 1000L);
  }

  @Override
  public String getTags() throws GitCommitIdExecutionException {
    final String result = runQuietGitCommand(canonical, nativeGitTimeoutInMs, "tag --contains " + evaluateOnCommit);
    return result.replace('\n', ',');
  }

  @Override
  public String getTag() throws GitCommitIdExecutionException {
    final String result = runQuietGitCommand(canonical, nativeGitTimeoutInMs, "tag --points-at " + evaluateOnCommit);
    return result.replace('\n', ',');
  }

  @Override
  public String getRemoteOriginUrl() throws GitCommitIdExecutionException {
    return getOriginRemote(canonical, nativeGitTimeoutInMs);
  }

  @Override
  public String getClosestTagName() throws GitCommitIdExecutionException {
    try {
      StringBuilder argumentsForGitDescribe = new StringBuilder();
      argumentsForGitDescribe.append("describe " + evaluateOnCommit + " --abbrev=0");
      if (gitDescribe != null) {
        if (gitDescribe.getTags()) {
          argumentsForGitDescribe.append(" --tags");
        }

        final String matchOption = gitDescribe.getMatch();
        if (matchOption != null && !matchOption.isEmpty()) {
          argumentsForGitDescribe.append(" --match=").append(matchOption);
        }
      }
      return runGitCommand(canonical, nativeGitTimeoutInMs, argumentsForGitDescribe.toString());
    } catch (NativeCommandException ignore) {
      // could not find any tags to describe
    }
    return "";
  }

  @Override
  public String getClosestTagCommitCount() throws GitCommitIdExecutionException {
    String closestTagName = getClosestTagName();
    if (closestTagName != null && !closestTagName.trim().isEmpty()) {
      return runQuietGitCommand(canonical, nativeGitTimeoutInMs, "rev-list " + closestTagName + ".." + evaluateOnCommit + " --count");
    }
    return "";
  }

  @Override
  public String getTotalCommitCount() throws GitCommitIdExecutionException {
    return runQuietGitCommand(canonical, nativeGitTimeoutInMs, "rev-list " + evaluateOnCommit + " --count");
  }

  @Override
  public void finalCleanUp() throws GitCommitIdExecutionException {
  }

  private String getOriginRemote(File directory, long nativeGitTimeoutInMs) throws GitCommitIdExecutionException {
    try {
      String remoteUrl = runGitCommand(directory, nativeGitTimeoutInMs, "ls-remote --get-url");

      return stripCredentialsFromOriginUrl(remoteUrl);
    } catch (NativeCommandException ignore) {
      // No remote configured to list refs from
    }
    return null;
  }

  /**
   * Runs a Git command and returns {@code true} if output was non empty.
   * Can be used to short cut reading output from command when we know it may be a rather long one.
   * Return true if the result is empty.
   **/
  private boolean tryCheckEmptyRunGitCommand(File directory, long nativeGitTimeoutInMs, String gitCommand) {
    try {
      String env = System.getenv("GIT_PATH");
      String exec = env == null ? "git" : env;
      String command = String.format("%s %s", exec, gitCommand);

      return getRunner().runEmpty(directory, nativeGitTimeoutInMs, command);
    } catch (IOException | GitCommitIdExecutionException ex) {
      log.error("Failed to run git command", ex);
      // Error means "non-empty"
      return false;
      // do nothing...
    }
  }

  private String runQuietGitCommand(File directory, long nativeGitTimeoutInMs, String gitCommand) throws GitCommitIdExecutionException {
    final String env = System.getenv("GIT_PATH");
    final String exec = env == null ? "git" : env;
    final String command = String.format("%s %s", exec, gitCommand);

    try {
      return getRunner().run(directory, nativeGitTimeoutInMs, command.trim()).trim();
    } catch (IOException e) {
      throw new GitCommitIdExecutionException(e);
    }
  }

  private String runGitCommand(File directory, long nativeGitTimeoutInMs, String gitCommand) throws GitCommitIdExecutionException {
    final String env = System.getenv("GIT_PATH");
    String exec = env == null ? "git" : env;
    final String command = String.format("%s %s", exec, gitCommand);

    try {
      return getRunner().run(directory, nativeGitTimeoutInMs, command.trim()).trim();
    } catch (NativeCommandException e) {
      throw e;
    } catch (IOException e) {
      throw new GitCommitIdExecutionException(e);
    }
  }

  private ProcessRunner getRunner() {
    if (runner == null) {
      runner = new JavaProcessRunner();
    }
    return runner;
  }

  public interface ProcessRunner {
    /** Run a command and return the entire output as a String - naive, we know.
     *
     * @param directory the directory where the command should be executed in
     * @param nativeGitTimeoutInMs the timeout in milliseconds before the command get's terminated
     * @param command the command to execute
     * @return the output obtained from stdout by running the command
     * @throws IOException if any underlying IOError occurred
     * @throws GitCommitIdExecutionException the command execution failed
     */
    String run(File directory, long nativeGitTimeoutInMs, String command) throws IOException, GitCommitIdExecutionException;

    /** Run a command and return false if it contains at least one output line
     *
     * @param directory the directory where the command should be executed in
     * @param nativeGitTimeoutInMs the timeout in milliseconds before the command get's terminated
     * @param command the command to execute
     * @return false if the output of the command contains at least one line on stdout, true otherwise
     * @throws IOException if any underlying IOError occurred
     * @throws GitCommitIdExecutionException the command execution failed
     */
    boolean runEmpty(File directory, long nativeGitTimeoutInMs, String command) throws IOException, GitCommitIdExecutionException;
  }

  public static class NativeCommandException extends GitCommitIdExecutionException {
    private static final long serialVersionUID = 3511033422542257748L;
    private final int exitCode;
    private final String command;
    private final File directory;
    private final String stdout;
    private final String stderr;

    public NativeCommandException(
            int exitCode,
            String command,
            File directory,
            String stdout,
            String stderr) {
      this.exitCode = exitCode;
      this.command = command;
      this.directory = directory;
      this.stdout = stdout;
      this.stderr = stderr;
    }

    public int getExitCode() {
      return exitCode;
    }

    public String getCommand() {
      return command;
    }

    public File getDirectory() {
      return directory;
    }

    public String getStdout() {
      return stdout;
    }

    public String getStderr() {
      return stderr;
    }

    @Override
    public String getMessage() {
      return format("Git command exited with invalid status [%d]: directory: `%s`, command: `%s`, stdout: `%s`, stderr: `%s`", exitCode, directory, command, stdout, stderr);
    }
  }

  protected static class JavaProcessRunner implements ProcessRunner {
    @Override
    public String run(File directory, long nativeGitTimeoutInMs, String command) throws IOException, GitCommitIdExecutionException {
      String output = "";
      try {
        final StringBuilder commandResult = new StringBuilder();

        final Consumer stdoutConsumer = line -> {
          if (line != null) {
            commandResult.append(line).append('\n');
          }
        };
        runProcess(directory, nativeGitTimeoutInMs, command, stdoutConsumer);

        output = commandResult.toString();
      } catch (final InterruptedException ex) {
        throw new IOException(ex);
      }
      return output;
    }

    @Override
    public boolean runEmpty(File directory, long nativeGitTimeoutInMs, String command) throws IOException, GitCommitIdExecutionException {
      final AtomicBoolean empty = new AtomicBoolean(true);

      try {
        final Consumer stdoutConsumer = line -> {
          empty.set(false);
          // Ignore the content of the line
        };
        runProcess(directory, nativeGitTimeoutInMs, command, stdoutConsumer);
      } catch (final InterruptedException ex) {
        throw new IOException(ex);
      }
      return empty.get(); // was non-empty
    }

    private void runProcess(
            File directory,
            long nativeGitTimeoutInMs,
            String command,
            final Consumer stdoutLineConsumer) throws InterruptedException, IOException, GitCommitIdExecutionException {

      final ProcessBuilder builder = new ProcessBuilder(command.split("\\s"));
      final Process proc = builder.directory(directory).start();

      try (ProcessHandler processHandler = new ProcessHandler(proc, stdoutLineConsumer)) {
        int exitValue = processHandler.exitValue(nativeGitTimeoutInMs, TimeUnit.MILLISECONDS);

        if (exitValue != 0) {
          throw new NativeCommandException(exitValue, command, directory, "", processHandler.getStderr());
        }

      } catch (TimeoutException e) {
        throw new RuntimeException(String.format("GIT-Command '%s' did not finish in %d milliseconds", command, nativeGitTimeoutInMs), e);
      } catch (ExecutionException e) {
        throw new RuntimeException(String.format("Executing GIT-Command '%s' threw an '%s' exception.", command, e.getMessage()), e);
      }
    }
  }

  @Override
  public AheadBehind getAheadBehind() throws GitCommitIdExecutionException {
    try {
      Optional remoteBranch = remoteBranch();
      if (!remoteBranch.isPresent()) {
        return AheadBehind.NO_REMOTE;
      }
      if (!offline) {
        fetch(remoteBranch.get());
      }
      String localBranchName = getBranchName();
      String ahead = runQuietGitCommand(canonical, nativeGitTimeoutInMs, "rev-list --right-only --count " + remoteBranch.get() + "..." + localBranchName);
      String behind = runQuietGitCommand(canonical, nativeGitTimeoutInMs, "rev-list --left-only --count " + remoteBranch.get() + "..." + localBranchName);
      return AheadBehind.of(ahead, behind);
    } catch (Exception e) {
      throw new GitCommitIdExecutionException("Failed to read ahead behind count: " + e.getMessage(), e);
    }
  }

  private Optional remoteBranch() {
    try {
      String remoteRef = runQuietGitCommand(canonical, nativeGitTimeoutInMs, "symbolic-ref -q " + evaluateOnCommit);
      if (remoteRef == null || remoteRef.isEmpty()) {
        log.debug("Could not find ref for: " + evaluateOnCommit);
        return Optional.empty();
      }
      String remoteBranch = runQuietGitCommand(canonical, nativeGitTimeoutInMs, "for-each-ref --format=%(upstream:short) " + remoteRef);
      return Optional.ofNullable(remoteBranch.isEmpty() ? null : remoteBranch);
    } catch (Exception e) {
      return Optional.empty();
    }
  }
  
  private void fetch(String remoteBranch) {
    try {
      runQuietGitCommand(canonical, nativeGitTimeoutInMs, "fetch " + remoteBranch.replaceFirst("/", " "));
    } catch (Exception e) {
      log.error("Failed to execute fetch", e);
    }
  }

  // Visible for testing
  public void setEvaluateOnCommit(String evaluateOnCommit) {
    this.evaluateOnCommit = evaluateOnCommit;
  }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy