/*
* This file is part of git-commit-id-plugin by Konrad 'ktoso' 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.Optional;
import com.google.common.base.Preconditions;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.GitCommand;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectReader;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
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.util.Pair;
import java.io.IOException;
import java.util.*;
/**
* Implements git's <pre>describe</pre> command.
*/
public class DescribeCommand extends GitCommand<DescribeResult> {
private LoggerBridge log;
private JGitCommon jGitCommon;
// 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 Repository} this command should interact with
* @param log logger bridge to direct logs to
*/
@NotNull
public static DescribeCommand on(Repository repo, LoggerBridge log) {
return new DescribeCommand(repo, log);
}
/**
* 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, @NotNull LoggerBridge log) {
super(repo);
this.jGitCommon = new JGitCommon(log);
this.log = log;
}
/**
* <pre>--always</pre>
*
* Show uniquely abbreviated commit object as fallback.
*
* <pre>true</pre> by default.
*/
@NotNull
public DescribeCommand always(boolean always) {
this.alwaysFlag = always;
log.info("--always = {}", always);
return this;
}
/**
* <pre>--long</pre>
*
* 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....).
*
* <pre>false</pre> by default.
*/
@NotNull
public DescribeCommand forceLongFormat(@Nullable Boolean forceLongFormat) {
if (forceLongFormat != null && forceLongFormat) {
this.forceLongFormat = true;
log.info("--long = {}", true);
}
return this;
}
/**
* <pre>--abbrev=N</pre>
*
* 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.
*
* 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 abbrev length) must be < 41. (Was:[%s])", n));
Preconditions.checkArgument(n >= 0, String.format("N (commit abbrev length) must be positive! (Was [%s])", n));
log.info("--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>Searching for lightweight tags is <b>false</b> by default.</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>
* 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 && includeLightweightTagsInSearch) {
tagsFlag = includeLightweightTagsInSearch;
log.info("--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 `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.info("--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) {
if (!"*".equals(pattern)) {
matchOption = Optional.fromNullable(pattern);
log.info("--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.info("The commit we're on is a Tag ([{}]) and forceLongFormat == false, returning.", tagName);
return new DescribeResult(tagName, dirty, dirtyOption);
}
// get commits, up until the nearest tag
List<RevCommit> commits = jGitCommon.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 = jGitCommon.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.
*
* 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 {
return JGitCommon.isRepositoryInDirtyState(repo);
}
@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.info("HEAD is [{}]", headCommit.getName());
return headCommit;
} catch (IOException ex) {
throw new RuntimeException("Unable to obtain HEAD commit!", ex);
}
}
// git commit id -> its tag (or tags)
private Map<ObjectId, List<String>> findTagObjectIds(@NotNull Repository repo, boolean tagsFlag) {
String matchPattern = createMatchPattern();
Map<ObjectId, List<DatedRevTag>> commitIdsToTags = jGitCommon.getCommitIdsToTags(repo, tagsFlag, matchPattern);
Map<ObjectId, List<String>> commitIdsToTagNames = jGitCommon.transformRevTagsMapToDateSortedTagNames(commitIdsToTags);
log.info("Created map: [{}]", commitIdsToTagNames);
return commitIdsToTagNames;
}
private String createMatchPattern() {
if (!matchOption.isPresent()) {
return ".*";
}
return "^refs/tags/\\Q" +
matchOption.get().replace("*", "\\E.*\\Q").replace("?", "\\E.\\Q") +
"\\E$";
}
}