DescribeCommand.java

  1. /*
  2.  * Copyright (C) 2013, CloudBees, Inc. and others
  3.  *
  4.  * This program and the accompanying materials are made available under the
  5.  * terms of the Eclipse Distribution License v. 1.0 which is available at
  6.  * https://www.eclipse.org/org/documents/edl-v10.php.
  7.  *
  8.  * SPDX-License-Identifier: BSD-3-Clause
  9.  */
  10. package org.eclipse.jgit.api;

  11. import static org.eclipse.jgit.lib.Constants.R_REFS;
  12. import static org.eclipse.jgit.lib.Constants.R_TAGS;
  13. import static org.eclipse.jgit.lib.TypedConfigGetter.UNSET_INT;

  14. import java.io.IOException;
  15. import java.text.MessageFormat;
  16. import java.util.ArrayList;
  17. import java.util.Collection;
  18. import java.util.Collections;
  19. import java.util.Comparator;
  20. import java.util.Date;
  21. import java.util.List;
  22. import java.util.Map;
  23. import java.util.Optional;
  24. import java.util.stream.Collectors;
  25. import java.util.stream.Stream;

  26. import org.eclipse.jgit.api.errors.GitAPIException;
  27. import org.eclipse.jgit.api.errors.JGitInternalException;
  28. import org.eclipse.jgit.api.errors.RefNotFoundException;
  29. import org.eclipse.jgit.errors.IncorrectObjectTypeException;
  30. import org.eclipse.jgit.errors.InvalidPatternException;
  31. import org.eclipse.jgit.errors.MissingObjectException;
  32. import org.eclipse.jgit.fnmatch.FileNameMatcher;
  33. import org.eclipse.jgit.internal.JGitText;
  34. import org.eclipse.jgit.lib.AbbrevConfig;
  35. import org.eclipse.jgit.lib.Constants;
  36. import org.eclipse.jgit.lib.ObjectId;
  37. import org.eclipse.jgit.lib.Ref;
  38. import org.eclipse.jgit.lib.Repository;
  39. import org.eclipse.jgit.revwalk.RevCommit;
  40. import org.eclipse.jgit.revwalk.RevFlag;
  41. import org.eclipse.jgit.revwalk.RevFlagSet;
  42. import org.eclipse.jgit.revwalk.RevTag;
  43. import org.eclipse.jgit.revwalk.RevWalk;

  44. /**
  45.  * Given a commit, show the most recent tag that is reachable from a commit.
  46.  *
  47.  * @since 3.2
  48.  */
  49. public class DescribeCommand extends GitCommand<String> {
  50.     private final RevWalk w;

  51.     /**
  52.      * Commit to describe.
  53.      */
  54.     private RevCommit target;

  55.     /**
  56.      * How many tags we'll consider as candidates.
  57.      * This can only go up to the number of flags JGit can support in a walk,
  58.      * which is 24.
  59.      */
  60.     private int maxCandidates = 10;

  61.     /**
  62.      * Whether to always use long output format or not.
  63.      */
  64.     private boolean longDesc;

  65.     /**
  66.      * Pattern matchers to be applied to tags under consideration.
  67.      */
  68.     private List<FileNameMatcher> matchers = new ArrayList<>();

  69.     /**
  70.      * Whether to use all refs in the refs/ namespace
  71.      */
  72.     private boolean useAll;

  73.     /**
  74.      * Whether to use all tags (incl. lightweight) or not.
  75.      */
  76.     private boolean useTags;

  77.     /**
  78.      * Whether to show a uniquely abbreviated commit hash as a fallback or not.
  79.      */
  80.     private boolean always;

  81.     /**
  82.      * The prefix length to use when abbreviating a commit hash.
  83.      */
  84.     private int abbrev = UNSET_INT;

  85.     /**
  86.      * Constructor for DescribeCommand.
  87.      *
  88.      * @param repo
  89.      *            the {@link org.eclipse.jgit.lib.Repository}
  90.      */
  91.     protected DescribeCommand(Repository repo) {
  92.         super(repo);
  93.         w = new RevWalk(repo);
  94.         w.setRetainBody(false);
  95.     }

  96.     /**
  97.      * Sets the commit to be described.
  98.      *
  99.      * @param target
  100.      *      A non-null object ID to be described.
  101.      * @return {@code this}
  102.      * @throws MissingObjectException
  103.      *             the supplied commit does not exist.
  104.      * @throws IncorrectObjectTypeException
  105.      *             the supplied id is not a commit or an annotated tag.
  106.      * @throws java.io.IOException
  107.      *             a pack file or loose object could not be read.
  108.      */
  109.     public DescribeCommand setTarget(ObjectId target) throws IOException {
  110.         this.target = w.parseCommit(target);
  111.         return this;
  112.     }

  113.     /**
  114.      * Sets the commit to be described.
  115.      *
  116.      * @param rev
  117.      *            Commit ID, tag, branch, ref, etc. See
  118.      *            {@link org.eclipse.jgit.lib.Repository#resolve(String)} for
  119.      *            allowed syntax.
  120.      * @return {@code this}
  121.      * @throws IncorrectObjectTypeException
  122.      *             the supplied id is not a commit or an annotated tag.
  123.      * @throws org.eclipse.jgit.api.errors.RefNotFoundException
  124.      *             the given rev didn't resolve to any object.
  125.      * @throws java.io.IOException
  126.      *             a pack file or loose object could not be read.
  127.      */
  128.     public DescribeCommand setTarget(String rev) throws IOException,
  129.             RefNotFoundException {
  130.         ObjectId id = repo.resolve(rev);
  131.         if (id == null)
  132.             throw new RefNotFoundException(MessageFormat.format(JGitText.get().refNotResolved, rev));
  133.         return setTarget(id);
  134.     }

  135.     /**
  136.      * Determine whether always to use the long format or not. When set to
  137.      * <code>true</code> the long format is used even the commit matches a tag.
  138.      *
  139.      * @param longDesc
  140.      *            <code>true</code> if always the long format should be used.
  141.      * @return {@code this}
  142.      * @see <a
  143.      *      href="https://www.kernel.org/pub/software/scm/git/docs/git-describe.html"
  144.      *      >Git documentation about describe</a>
  145.      * @since 4.0
  146.      */
  147.     public DescribeCommand setLong(boolean longDesc) {
  148.         this.longDesc = longDesc;
  149.         return this;
  150.     }

  151.     /**
  152.      * Instead of using only the annotated tags, use any ref found in refs/
  153.      * namespace. This option enables matching any known branch,
  154.      * remote-tracking branch, or lightweight tag.
  155.      *
  156.      * @param all
  157.      *            <code>true</code> enables matching any ref found in refs/
  158.      *            like setting option --all in c git
  159.      * @return {@code this}
  160.      * @since 5.10
  161.      */
  162.     public DescribeCommand setAll(boolean all) {
  163.         this.useAll = all;
  164.         return this;
  165.     }

  166.     /**
  167.      * Instead of using only the annotated tags, use any tag found in refs/tags
  168.      * namespace. This option enables matching lightweight (non-annotated) tags
  169.      * or not.
  170.      *
  171.      * @param tags
  172.      *            <code>true</code> enables matching lightweight (non-annotated)
  173.      *            tags like setting option --tags in c git
  174.      * @return {@code this}
  175.      * @since 5.0
  176.      */
  177.     public DescribeCommand setTags(boolean tags) {
  178.         this.useTags = tags;
  179.         return this;
  180.     }

  181.     /**
  182.      * Always describe the commit by eventually falling back to a uniquely
  183.      * abbreviated commit hash if no other name matches.
  184.      *
  185.      * @param always
  186.      *            <code>true</code> enables falling back to a uniquely
  187.      *            abbreviated commit hash
  188.      * @return {@code this}
  189.      * @since 5.4
  190.      */
  191.     public DescribeCommand setAlways(boolean always) {
  192.         this.always = always;
  193.         return this;
  194.     }

  195.     /**
  196.      * Sets the prefix length to use when abbreviating an object SHA-1.
  197.      *
  198.      * @param abbrev
  199.      *            minimum length of the abbreviated string. Must be in the range
  200.      *            [{@value AbbrevConfig#MIN_ABBREV},
  201.      *            {@value Constants#OBJECT_ID_STRING_LENGTH}].
  202.      * @return {@code this}
  203.      * @since 6.1
  204.      */
  205.     public DescribeCommand setAbbrev(int abbrev) {
  206.         if (abbrev == 0) {
  207.             this.abbrev = 0;
  208.         } else {
  209.             this.abbrev = AbbrevConfig.capAbbrev(abbrev);
  210.         }
  211.         return this;
  212.     }

  213.     private String longDescription(Ref tag, int depth, ObjectId tip)
  214.             throws IOException {
  215.         if (abbrev == 0) {
  216.             return formatRefName(tag.getName());
  217.         }
  218.         return String.format("%s-%d-g%s", formatRefName(tag.getName()), //$NON-NLS-1$
  219.                 Integer.valueOf(depth),
  220.                 w.getObjectReader().abbreviate(tip, abbrev).name());
  221.     }

  222.     /**
  223.      * Sets one or more {@code glob(7)} patterns that tags must match to be
  224.      * considered. If multiple patterns are provided, tags only need match one
  225.      * of them.
  226.      *
  227.      * @param patterns
  228.      *            the {@code glob(7)} pattern or patterns
  229.      * @return {@code this}
  230.      * @throws org.eclipse.jgit.errors.InvalidPatternException
  231.      *             if the pattern passed in was invalid.
  232.      * @see <a href=
  233.      *      "https://www.kernel.org/pub/software/scm/git/docs/git-describe.html"
  234.      *      >Git documentation about describe</a>
  235.      * @since 4.9
  236.      */
  237.     public DescribeCommand setMatch(String... patterns) throws InvalidPatternException {
  238.         for (String p : patterns) {
  239.             matchers.add(new FileNameMatcher(p, null));
  240.         }
  241.         return this;
  242.     }

  243.     private final Comparator<Ref> TAG_TIE_BREAKER = new Comparator<>() {

  244.         @Override
  245.         public int compare(Ref o1, Ref o2) {
  246.             try {
  247.                 return tagDate(o2).compareTo(tagDate(o1));
  248.             } catch (IOException e) {
  249.                 return 0;
  250.             }
  251.         }

  252.         private Date tagDate(Ref tag) throws IOException {
  253.             RevTag t = w.parseTag(tag.getObjectId());
  254.             w.parseBody(t);
  255.             return t.getTaggerIdent().getWhen();
  256.         }
  257.     };

  258.     private Optional<Ref> getBestMatch(List<Ref> tags) {
  259.         if (tags == null || tags.isEmpty()) {
  260.             return Optional.empty();
  261.         } else if (matchers.isEmpty()) {
  262.             Collections.sort(tags, TAG_TIE_BREAKER);
  263.             return Optional.of(tags.get(0));
  264.         } else {
  265.             // Find the first tag that matches in the stream of all tags
  266.             // filtered by matchers ordered by tie break order
  267.             Stream<Ref> matchingTags = Stream.empty();
  268.             for (FileNameMatcher matcher : matchers) {
  269.                 Stream<Ref> m = tags.stream().filter(
  270.                         tag -> {
  271.                             matcher.append(formatRefName(tag.getName()));
  272.                             boolean result = matcher.isMatch();
  273.                             matcher.reset();
  274.                             return result;
  275.                         });
  276.                 matchingTags = Stream.of(matchingTags, m).flatMap(i -> i);
  277.             }
  278.             return matchingTags.sorted(TAG_TIE_BREAKER).findFirst();
  279.         }
  280.     }

  281.     private ObjectId getObjectIdFromRef(Ref r) throws JGitInternalException {
  282.         try {
  283.             ObjectId key = repo.getRefDatabase().peel(r).getPeeledObjectId();
  284.             if (key == null) {
  285.                 key = r.getObjectId();
  286.             }
  287.             return key;
  288.         } catch (IOException e) {
  289.             throw new JGitInternalException(e.getMessage(), e);
  290.         }
  291.     }

  292.     /**
  293.      * {@inheritDoc}
  294.      * <p>
  295.      * Describes the specified commit. Target defaults to HEAD if no commit was
  296.      * set explicitly.
  297.      */
  298.     @Override
  299.     public String call() throws GitAPIException {
  300.         try {
  301.             checkCallable();
  302.             if (target == null) {
  303.                 setTarget(Constants.HEAD);
  304.             }
  305.             if (abbrev == UNSET_INT) {
  306.                 abbrev = AbbrevConfig.parseFromConfig(repo).get();
  307.             }

  308.             Collection<Ref> tagList = repo.getRefDatabase()
  309.                     .getRefsByPrefix(useAll ? R_REFS : R_TAGS);
  310.             Map<ObjectId, List<Ref>> tags = tagList.stream()
  311.                     .filter(this::filterLightweightTags)
  312.                     .collect(Collectors.groupingBy(this::getObjectIdFromRef));

  313.             // combined flags of all the candidate instances
  314.             final RevFlagSet allFlags = new RevFlagSet();

  315.             /**
  316.              * Tracks the depth of each tag as we find them.
  317.              */
  318.             class Candidate {
  319.                 final Ref tag;
  320.                 final RevFlag flag;

  321.                 /**
  322.                  * This field counts number of commits that are reachable from
  323.                  * the tip but not reachable from the tag.
  324.                  */
  325.                 int depth;

  326.                 Candidate(RevCommit commit, Ref tag) {
  327.                     this.tag = tag;
  328.                     this.flag = w.newFlag(tag.getName());
  329.                     // we'll mark all the nodes reachable from this tag accordingly
  330.                     allFlags.add(flag);
  331.                     w.carry(flag);
  332.                     commit.add(flag);
  333.                     // As of this writing, JGit carries a flag from a child to its parents
  334.                     // right before RevWalk.next() returns, so all the flags that are added
  335.                     // must be manually carried to its parents. If that gets fixed,
  336.                     // this will be unnecessary.
  337.                     commit.carry(flag);
  338.                 }

  339.                 /**
  340.                  * Does this tag contain the given commit?
  341.                  */
  342.                 boolean reaches(RevCommit c) {
  343.                     return c.has(flag);
  344.                 }

  345.                 String describe(ObjectId tip) throws IOException {
  346.                     return longDescription(tag, depth, tip);
  347.                 }

  348.             }
  349.             List<Candidate> candidates = new ArrayList<>();    // all the candidates we find

  350.             // is the target already pointing to a suitable tag? if so, we are done!
  351.             Optional<Ref> bestMatch = getBestMatch(tags.get(target));
  352.             if (bestMatch.isPresent()) {
  353.                 return longDesc ? longDescription(bestMatch.get(), 0, target) :
  354.                         formatRefName(bestMatch.get().getName());
  355.             }

  356.             w.markStart(target);

  357.             int seen = 0;   // commit seen thus far
  358.             RevCommit c;
  359.             while ((c = w.next()) != null) {
  360.                 if (!c.hasAny(allFlags)) {
  361.                     // if a tag already dominates this commit,
  362.                     // then there's no point in picking a tag on this commit
  363.                     // since the one that dominates it is always more preferable
  364.                     bestMatch = getBestMatch(tags.get(c));
  365.                     if (bestMatch.isPresent()) {
  366.                         Candidate cd = new Candidate(c, bestMatch.get());
  367.                         candidates.add(cd);
  368.                         cd.depth = seen;
  369.                     }
  370.                 }

  371.                 // if the newly discovered commit isn't reachable from a tag that we've seen
  372.                 // it counts toward the total depth.
  373.                 for (Candidate cd : candidates) {
  374.                     if (!cd.reaches(c))
  375.                         cd.depth++;
  376.                 }

  377.                 // if we have search going for enough tags, we will start
  378.                 // closing down. JGit can only give us a finite number of bits,
  379.                 // so we can't track all tags even if we wanted to.
  380.                 if (candidates.size() >= maxCandidates)
  381.                     break;

  382.                 // TODO: if all the commits in the queue of RevWalk has allFlags
  383.                 // there's no point in continuing search as we'll not discover any more
  384.                 // tags. But RevWalk doesn't expose this.
  385.                 seen++;
  386.             }

  387.             // at this point we aren't adding any more tags to our search,
  388.             // but we still need to count all the depths correctly.
  389.             while ((c = w.next()) != null) {
  390.                 if (c.hasAll(allFlags)) {
  391.                     // no point in visiting further from here, so cut the search here
  392.                     for (RevCommit p : c.getParents())
  393.                         p.add(RevFlag.SEEN);
  394.                 } else {
  395.                     for (Candidate cd : candidates) {
  396.                         if (!cd.reaches(c))
  397.                             cd.depth++;
  398.                     }
  399.                 }
  400.             }

  401.             // if all the nodes are dominated by all the tags, the walk stops
  402.             if (candidates.isEmpty()) {
  403.                 return always
  404.                         ? w.getObjectReader()
  405.                                 .abbreviate(target,
  406.                                         AbbrevConfig.capAbbrev(abbrev))
  407.                                 .name()
  408.                         : null;
  409.             }

  410.             Candidate best = Collections.min(candidates,
  411.                     (Candidate o1, Candidate o2) -> o1.depth - o2.depth);

  412.             return best.describe(target);
  413.         } catch (IOException e) {
  414.             throw new JGitInternalException(e.getMessage(), e);
  415.         } finally {
  416.             setCallable(false);
  417.             w.close();
  418.         }
  419.     }

  420.     /**
  421.      * Removes the refs/ or refs/tags prefix from tag names
  422.      * @param name the name of the tag
  423.      * @return the tag name with its prefix removed
  424.      */
  425.     private String formatRefName(String name) {
  426.         return name.startsWith(R_TAGS) ? name.substring(R_TAGS.length()) :
  427.                 name.substring(R_REFS.length());
  428.     }

  429.     /**
  430.      * Whether we use lightweight tags or not for describe Candidates
  431.      *
  432.      * @param ref
  433.      *            reference under inspection
  434.      * @return true if it should be used for describe or not regarding
  435.      *         {@link org.eclipse.jgit.api.DescribeCommand#useTags}
  436.      */
  437.     @SuppressWarnings("null")
  438.     private boolean filterLightweightTags(Ref ref) {
  439.         ObjectId id = ref.getObjectId();
  440.         try {
  441.             return this.useAll || this.useTags || (id != null && (w.parseTag(id) != null));
  442.         } catch (IOException e) {
  443.             return false;
  444.         }
  445.     }
  446. }