Package pl.project13.jgit

Source Code of pl.project13.jgit.DescribeCommand

/*
* This file is part of git-commit-id-plugin by Konrad Malawski <konrad.malawski@java.pl>
*
* git-commit-id-plugin 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 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.  If not, see <http://www.gnu.org/licenses/>.
*/

package pl.project13.jgit;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Function;
import com.google.common.base.Optional;
import com.google.common.base.Preconditions;
import com.google.common.base.Throwables;
import com.google.common.collect.Lists;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.GitCommand;
import org.eclipse.jgit.api.Status;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.errors.IncorrectObjectTypeException;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectReader;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevTag;
import org.eclipse.jgit.revwalk.RevWalk;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import pl.project13.jgit.dummy.DatedRevTag;
import pl.project13.maven.git.GitDescribeConfig;
import pl.project13.maven.git.log.LoggerBridge;
import pl.project13.maven.git.log.StdOutLoggerBridge;
import pl.project13.maven.git.util.Pair;

import java.io.IOException;
import java.util.*;
import java.util.regex.Pattern;

import static com.google.common.collect.Lists.newArrayList;
import static com.google.common.collect.Lists.newLinkedList;
import static com.google.common.collect.Maps.newHashMap;
import static com.google.common.collect.Sets.newHashSet;

/**
* Implements git's <pre>describe</pre> command.
* <p/>
* <code><pre>
*  usage: git describe [options] <committish>*
*  or: git describe [options] --dirty
* <p/>
*   --contains            find the tag that comes after the commit
*   --debug               debug search strategy on stderr
*   --all                 use any ref in .git/refs
*   --tags                use any tag in .git/refs/tags
*   --long                always use long format
*   --abbrev[=<n>]        use <n> digits to display SHA-1s
*   --exact-match         only output exact matches
*   --candidates <n>      consider <n> most recent tags (default: 10)
*   --match <pattern>     only consider tags matching <pattern>
*   --always              show abbreviated commit object as fallback
*   --dirty[=<mark>]      append <mark> on dirty working tree (default: "-dirty")
* </pre></code>
*
* @author <a href="mailto:konrad.malawski@java.pl">Konrad 'ktoso' Malawski</a>
*/
public class DescribeCommand extends GitCommand<DescribeResult> {

  private LoggerBridge loggerBridge;

//   todo not yet implemented options:
//  private boolean containsFlag = false;
//  private boolean allFlag = false;
//  private boolean tagsFlag = false;
//  private Optional<Integer> candidatesOption = Optional.of(10);
//  private boolean exactMatchFlag = false;

  private Optional<String> matchOption = Optional.absent();

  /**
   * How many chars of the commit hash should be displayed? 7 is the default used by git.
   */
  private int abbrev = 7;

  /**
   * Skipping lightweight tags by default - that's how git-describe works by default.
   * {@link DescribeCommand#tags(Boolean)} for more details.
   */
  private boolean tagsFlag = false;

  private boolean alwaysFlag = true;

  /**
   * Corresponds to <pre>--long</pre>. Always use the <pre>TAG-N-HASH</pre> format, even when ON a tag.
   */
  private boolean forceLongFormat = false;

  /**
   * The string marker (such as "DEV") to be suffixed to the describe result when the working directory is dirty
   */
  private Optional<String> dirtyOption = Optional.absent();

  /**
   * Creates a new describe command which interacts with a single repository
   *
   * @param repo the {@link org.eclipse.jgit.lib.Repository} this command should interact with
   */
  @NotNull
  public static DescribeCommand on(Repository repo) {
    return new DescribeCommand(repo);
  }

  /**
   * Creates a new describe command which interacts with a single repository
   *
   * @param repo the {@link org.eclipse.jgit.lib.Repository} this command should interact with
   */
  private DescribeCommand(Repository repo) {
    this(repo, true);
  }

  private DescribeCommand(Repository repo, boolean verbose) {
    super(repo);
    initDefaultLoggerBridge(verbose);
    setVerbose(verbose);
  }

  private void initDefaultLoggerBridge(boolean verbose) {
    loggerBridge = new StdOutLoggerBridge(verbose);
  }

  @NotNull
  public DescribeCommand setVerbose(boolean verbose) {
    loggerBridge.setVerbose(verbose);
    return this;
  }

  @NotNull
  public DescribeCommand withLoggerBridge(LoggerBridge bridge) {
    this.loggerBridge = bridge;
    return this;
  }

  /**
   * <pre>--always</pre>
   * <p/>
   * Show uniquely abbreviated commit object as fallback.
   * <p/>
   * <pre>true</pre> by default.
   */
  @NotNull
  public DescribeCommand always(boolean always) {
    this.alwaysFlag = always;
    log("--always =", always);
    return this;
  }

  /**
   * <pre>--long</pre>
   * <p/>
   * Always output the long format (the tag, the number of commits and the abbreviated commit name)
   * even when it matches a tag. This is useful when you want to see parts of the commit object name
   * in "describe" output, even when the commit in question happens to be a tagged version. Instead
   * of just emitting the tag name, it will describe such a commit as v1.2-0-gdeadbee (0th commit
   * since tag v1.2 that points at object deadbee....).
   * <p/>
   * <pre>false</pre> by default.
   */
  @NotNull
  public DescribeCommand forceLongFormat(@Nullable Boolean forceLongFormat) {
    if (forceLongFormat != null) {
      this.forceLongFormat = forceLongFormat;
      log("--long =", forceLongFormat);
    }
    return this;
  }

  /**
   * <pre>--abbrev=N</pre>
   * <p/>
   * Instead of using the default <em>7 hexadecimal digits</em> as the abbreviated object name,
   * use <b>N</b> digits, or as many digits as needed to form a unique object name.
   * <p/>
   * An <n> of 0 will suppress long format, only showing the closest tag.
   */
  @NotNull
  public DescribeCommand abbrev(@Nullable Integer n) {
    if (n != null) {
      Preconditions.checkArgument(n < 41, String.format("N (commit abbres length) must be < 41. (Was:[%s])", n));
      Preconditions.checkArgument(n >= 0, String.format("N (commit abbrev length) must be positive! (Was [%s])", n));
      log("--abbrev =", n);
      abbrev = n;
    }
    return this;
  }

  /**
   * <pre>--tags</pre>
   * <p>
   * Instead of using only the annotated tags, use any tag found in .git/refs/tags.
   * This option enables matching a lightweight (non-annotated) tag.
   * </p>
   * <p/>
   * <p>Searching for lightweight tags is <b>false</b> by default.</p>
   * <p/>
   * Example:
   * <pre>
   *    b6a73ed - (HEAD, master)
   *    d37a598 - (v1.0-fixed-stuff) - a lightweight tag (with no message)
   *    9597545 - (v1.0) - an annotated tag
   *
   *  > git describe
   *    annotated-tag-2-gb6a73ed     # the nearest "annotated" tag is found
   *
   *  > git describe --tags
   *    lightweight-tag-1-gb6a73ed   # the nearest tag (including lightweights) is found
   * </pre>
   * <p/>
   * <p>
   * Using only annotated tags to mark builds may be useful if you're using tags to help yourself with annotating
   * things like "i'll get back to that" etc - you don't need such tags to be exposed. But if you want lightweight
   * tags to be included in the search, enable this option.
   * </p>
   */
  @NotNull
  public DescribeCommand tags(@Nullable Boolean includeLightweightTagsInSearch) {
    if (includeLightweightTagsInSearch != null) {
      tagsFlag = includeLightweightTagsInSearch;
      log("--tags =", includeLightweightTagsInSearch);
    }
    return this;
  }

  /**
   * Alias for {@link DescribeCommand#tags(Boolean)} with <b>true</b> value
   */
  public DescribeCommand tags() {
    return tags(true);
  }

  /**
   * Apply all configuration options passed in with {@param config}.
   * If a setting is null, it will not be applied - so for abbrev for example, the default 7 would be used.
   *
   * @return itself, after applying the settings
   */
  @NotNull
  public DescribeCommand apply(@Nullable GitDescribeConfig config) {
    if (config != null) {
      always(config.isAlways());
      dirty(config.getDirty());
      abbrev(config.getAbbrev());
      forceLongFormat(config.getForceLongFormat());
      tags(config.getTags());
      match(config.getMatch());
    }
    return this;
  }

  /**
   * <pre>--dirty[=mark]</pre>
   * Describe the working tree. It means describe HEAD and appends mark (<pre>-dirty</pre> by default) if the
   * working tree is dirty.
   *
   * @param dirtyMarker the marker name to be appended to the describe output when the workspace is dirty
   * @return itself, to allow fluent configuration
   */
  @NotNull
  public DescribeCommand dirty(@Nullable String dirtyMarker) {
    Optional<String> option = Optional.fromNullable(dirtyMarker);
    log("--dirty =", option.or(""));
    this.dirtyOption = option;
    return this;
  }

  /**
   * <pre>--match glob-pattern</pre>
   * Consider only those tags which match the given glob pattern.
   *
   * @param pattern the glob style pattern to match against the tag names
   * @return itself, to allow fluent configuration
   */
  @NotNull
  public DescribeCommand match(@Nullable String pattern) {
    matchOption = Optional.fromNullable(pattern);
    log("--match =", matchOption.or(""));
    return this;
  }

  @Override
  public DescribeResult call() throws GitAPIException {
    // needed for abbrev id's calculation
    ObjectReader objectReader = repo.newObjectReader();

    // get tags
    Map<ObjectId, List<String>> tagObjectIdToName = findTagObjectIds(repo, tagsFlag);

    // get current commit
    RevCommit headCommit = findHeadObjectId(repo);
    ObjectId headCommitId = headCommit.getId();

    // check if dirty
    boolean dirty = findDirtyState(repo);

    if (hasTags(headCommit, tagObjectIdToName) && !forceLongFormat) {
      String tagName = tagObjectIdToName.get(headCommit).iterator().next();
      log("The commit we're on is a Tag ([",tagName,"]) and forceLongFormat == false, returning.");

      return new DescribeResult(tagName, dirty, dirtyOption);
    }

    // get commits, up until the nearest tag
    List<RevCommit> commits = findCommitsUntilSomeTag(repo, headCommit, tagObjectIdToName);

    // if there is no tags or any tag is not on that branch then return generic describe
    if (foundZeroTags(tagObjectIdToName) || commits.isEmpty()) {
      return new DescribeResult(objectReader, headCommitId, dirty, dirtyOption)
          .withCommitIdAbbrev(abbrev);
    }

    // check how far away from a tag we are

    int distance = distanceBetween(repo, headCommit, commits.get(0));
    String tagName = tagObjectIdToName.get(commits.get(0)).iterator().next();
    Pair<Integer, String> howFarFromWhichTag = Pair.of(distance, tagName);

    // if it's null, no tag's were found etc, so let's return just the commit-id
    return createDescribeResult(objectReader, headCommitId, dirty, howFarFromWhichTag);
  }

  /**
   * Prepares the final result of this command.
   * It tries to put as much information as possible into the result,
   * and will fallback to a plain commit hash if nothing better is returnable.
   * <p/>
   * The exact logic is following what <pre>git-describe</pre> would do.
   */
  private DescribeResult createDescribeResult(ObjectReader objectReader, ObjectId headCommitId, boolean dirty, @Nullable Pair<Integer, String> howFarFromWhichTag) {
    if (howFarFromWhichTag == null) {
      return new DescribeResult(objectReader, headCommitId, dirty, dirtyOption)
          .withCommitIdAbbrev(abbrev);

    } else if (howFarFromWhichTag.first > 0 || forceLongFormat) {
      return new DescribeResult(objectReader, howFarFromWhichTag.second, howFarFromWhichTag.first, headCommitId, dirty, dirtyOption, forceLongFormat)
          .withCommitIdAbbrev(abbrev); // we're a bit away from a tag

    } else if (howFarFromWhichTag.first == 0) {
      return new DescribeResult(howFarFromWhichTag.second)
          .withCommitIdAbbrev(abbrev); // we're ON a tag

    } else if (alwaysFlag) {
      return new DescribeResult(objectReader, headCommitId)
          .withCommitIdAbbrev(abbrev); // we have no tags! display the commit

    } else {
      return DescribeResult.EMPTY;
    }
  }

  private static boolean foundZeroTags(@NotNull Map<ObjectId, List<String>> tags) {
    return tags.isEmpty();
  }

  @VisibleForTesting
  boolean findDirtyState(Repository repo) throws GitAPIException {
    Git git = Git.wrap(repo);
    Status status = git.status().call();

    // Git describe doesn't mind about untracked files when checking if
    // repo is dirty. JGit does this, so we cannot use the isClean method
    // to get the same behaviour. Instead check dirty state without
    // status.getUntracked().isEmpty()
    boolean isDirty = !(status.getAdded().isEmpty()
        && status.getChanged().isEmpty()
        && status.getRemoved().isEmpty()
        && status.getMissing().isEmpty()
        && status.getModified().isEmpty()
        && status.getConflicting().isEmpty());

    log("Repo is in dirty state [", isDirty, "]");
    return isDirty;
  }

  @VisibleForTesting
  static boolean hasTags(ObjectId headCommit, @NotNull Map<ObjectId, List<String>> tagObjectIdToName) {
    return tagObjectIdToName.containsKey(headCommit);
  }

  RevCommit findHeadObjectId(@NotNull Repository repo) throws RuntimeException {
    try {
      ObjectId headId = repo.resolve("HEAD");

      RevWalk walk = new RevWalk(repo);
      RevCommit headCommit = walk.lookupCommit(headId);
      walk.dispose();

      log("HEAD is [",headCommit.getName(),"] ");
      return headCommit;
    } catch (IOException ex) {
      throw new RuntimeException("Unable to obtain HEAD commit!", ex);
    }
  }

  private List<RevCommit> findCommitsUntilSomeTag(Repository repo, RevCommit head, @NotNull Map<ObjectId, List<String>> tagObjectIdToName) {
    RevWalk revWalk = new RevWalk(repo);
    try {
      revWalk.markStart(head);

      for (RevCommit commit : revWalk) {
        ObjectId objId = commit.getId();
        if (tagObjectIdToName.size() > 0) {
          List<String> maybeList = tagObjectIdToName.get(objId);
          if (maybeList != null && maybeList.get(0) != null) {
            return Collections.singletonList(commit);
          }
        }
      }

      return Collections.emptyList();
    } catch (Exception e) {
      throw new RuntimeException("Unable to find commits until some tag", e);
    }
  }

  /**
   * Calculates the distance (number of commits) between the given parent and child commits.
   *
   * @return distance (number of commits) between the given commits
   * @see <a href="https://github.com/mdonoughe/jgit-describe/blob/master/src/org/mdonoughe/JGitDescribeTask.java">mdonoughe/jgit-describe/blob/master/src/org/mdonoughe/JGitDescribeTask.java</a>
   */
  private int distanceBetween(@NotNull Repository repo, @NotNull RevCommit child, @NotNull RevCommit parent) {
    RevWalk revWalk = new RevWalk(repo);

    try {
      revWalk.markStart(child);

      Set<ObjectId> seena = newHashSet();
      Set<ObjectId> seenb = newHashSet();
      Queue<RevCommit> q = newLinkedList();

      q.add(revWalk.parseCommit(child));
      int distance = 0;
      ObjectId parentId = parent.getId();

      while (q.size() > 0) {
        RevCommit commit = q.remove();
        ObjectId commitId = commit.getId();

        if (seena.contains(commitId)) {
          continue;
        }
        seena.add(commitId);

        if (parentId.equals(commitId)) {
          // don't consider commits that are included in this commit
          seeAllParents(revWalk, commit, seenb);
          // remove things we shouldn't have included
          for (ObjectId oid : seenb) {
            if (seena.contains(oid)) {
              distance--;
            }
          }
          seena.addAll(seenb);
          continue;
        }

        for (ObjectId oid : commit.getParents()) {
          if (!seena.contains(oid)) {
            q.add(revWalk.parseCommit(oid));
          }
        }
        distance++;
      }

      return distance;

    } catch (Exception e) {
      throw new RuntimeException(String.format("Unable to calculate distance between [%s] and [%s]", child, parent), e);
    } finally {
      revWalk.dispose();
    }
  }

  private static void seeAllParents(@NotNull RevWalk revWalk, RevCommit child, @NotNull Set<ObjectId> seen) throws IOException {
    Queue<RevCommit> q = newLinkedList();
    q.add(child);

    while (q.size() > 0) {
      RevCommit commit = q.remove();
      for (ObjectId oid : commit.getParents()) {
        if (seen.contains(oid)) {
          continue;
        }
        seen.add(oid);
        q.add(revWalk.parseCommit(oid));
      }
    }
  }

  // git commit id -> its tag (or tags)
  private Map<ObjectId, List<String>> findTagObjectIds(@NotNull Repository repo, boolean tagsFlag) {
    Map<ObjectId, List<DatedRevTag>> commitIdsToTags = newHashMap();

    RevWalk walk = new RevWalk(repo);
    try {
      walk.markStart(walk.parseCommit(repo.resolve("HEAD")));

      List<Ref> tagRefs = Git.wrap(repo).tagList().call();
      String matchPattern = createMatchPattern();
      Pattern regex = Pattern.compile(matchPattern);
      log("Tag refs [", tagRefs, "]");

      for (Ref tagRef : tagRefs) {
        walk.reset();
        String name = tagRef.getName();
        if (!regex.matcher(name).matches()) {
          log("Skipping tagRef with name [", name, "] as it doesn't match [", matchPattern, "]");
          continue;
        }
        ObjectId resolvedCommitId = repo.resolve(name);

        // todo that's a bit of a hack...
        try {
          final RevTag revTag = walk.parseTag(resolvedCommitId);
          ObjectId taggedCommitId = revTag.getObject().getId();
          log("Resolved tag [",revTag.getTagName(),"] [",revTag.getTaggerIdent(),"], points at [",taggedCommitId,"] ");

          // sometimes a tag, may point to another tag, so we need to unpack it
          while (isTagId(taggedCommitId)) {
            taggedCommitId = walk.parseTag(taggedCommitId).getObject().getId();
          }

          if (commitIdsToTags.containsKey(taggedCommitId)) {
            commitIdsToTags.get(taggedCommitId).add(new DatedRevTag(revTag));
          } else {
            commitIdsToTags.put(taggedCommitId, newArrayList(new DatedRevTag(revTag)));
          }

        } catch (IncorrectObjectTypeException ex) {
          // it's an lightweight tag! (yeah, really)
          if (tagsFlag) {
            // --tags means "include lightweight tags"
            log("Including lightweight tag [", name, "]");

            DatedRevTag datedRevTag = new DatedRevTag(resolvedCommitId, name);

            if (commitIdsToTags.containsKey(resolvedCommitId)) {
              commitIdsToTags.get(resolvedCommitId).add(datedRevTag);
            } else {
              commitIdsToTags.put(resolvedCommitId, newArrayList(datedRevTag));
            }
          }
        } catch (Exception ignored) {
          error("Failed while parsing [",tagRef,"] -- ", Throwables.getStackTraceAsString(ignored));
        }
      }

      for (Map.Entry<ObjectId, List<DatedRevTag>> entry : commitIdsToTags.entrySet()) {
        log("key [",entry.getKey(),"], tags => [",entry.getValue(),"] ");
      }

      Map<ObjectId, List<String>> commitIdsToTagNames = transformRevTagsMapToDateSortedTagNames(commitIdsToTags);

      log("Created map: [",commitIdsToTagNames,"] ");

      return commitIdsToTagNames;
    } catch (Exception e) {
      log("Unable to locate tags\n[",Throwables.getStackTraceAsString(e),"]");
    } finally {
      walk.release();
    }

    return Collections.emptyMap();
  }

  /** Checks if the given object id resolved to a tag object */
  private boolean isTagId(ObjectId objectId) {
    return objectId.toString().startsWith("tag ");
  }

  private HashMap<ObjectId, List<String>> transformRevTagsMapToDateSortedTagNames(Map<ObjectId, List<DatedRevTag>> commitIdsToTags) {
    HashMap<ObjectId, List<String>> commitIdsToTagNames = newHashMap();
    for (Map.Entry<ObjectId, List<DatedRevTag>> objectIdListEntry : commitIdsToTags.entrySet()) {
      List<DatedRevTag> tags = objectIdListEntry.getValue();

      List<DatedRevTag> newTags = newArrayList(tags);
      Collections.sort(newTags, new Comparator<DatedRevTag>() {
        @Override
        public int compare(DatedRevTag revTag, DatedRevTag revTag2) {
          return revTag2.date.compareTo(revTag.date);
        }
      });

      List<String> tagNames = Lists.transform(newTags, new Function<DatedRevTag, String>() {
        @Override
        public String apply(DatedRevTag input) {
          return trimFullTagName(input.tagName);
        }
      });

      commitIdsToTagNames.put(objectIdListEntry.getKey(), tagNames);
    }
    return commitIdsToTagNames;
  }

  private String createMatchPattern() {
    if (!matchOption.isPresent()) {
      return ".*";
    }

    StringBuffer buf = new StringBuffer();
    buf.append("^refs/tags/\\Q");
    buf.append(matchOption.get().replace("*", "\\E.*\\Q").replace("?", "\\E.\\Q"));
    buf.append("\\E$");
    return buf.toString();
  }

  @VisibleForTesting
  static String trimFullTagName(@NotNull String tagName) {
    return tagName.replaceFirst("refs/tags/", "");
  }

  private void log(Object... parts) {
    loggerBridge.log(parts);
  }

  private void error(Object... parts) {
    loggerBridge.error(parts);
  }

}
TOP

Related Classes of pl.project13.jgit.DescribeCommand

TOP
Copyright © 2018 www.massapi.com. All rights reserved.
All source code are property of their respective owners. Java is a trademark of Sun Microsystems, Inc and owned by ORACLE Inc. Contact coftware#gmail.com.