/* Copyright (c) 2012-2014 Boundless and others.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Distribution License v1.0
* which accompanies this distribution, and is available at
* https://www.eclipse.org/org/documents/edl-v10.html
*
* Contributors:
* Gabriel Roldan (Boundless) - initial implementation
*/
package org.locationtech.geogig.api.porcelain;
import java.util.Date;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.Stack;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.geotools.util.Range;
import org.locationtech.geogig.api.AbstractGeoGigOp;
import org.locationtech.geogig.api.NodeRef;
import org.locationtech.geogig.api.ObjectId;
import org.locationtech.geogig.api.Ref;
import org.locationtech.geogig.api.RevCommit;
import org.locationtech.geogig.api.RevTree;
import org.locationtech.geogig.api.plumbing.FindTreeChild;
import org.locationtech.geogig.api.plumbing.RevParse;
import org.locationtech.geogig.di.CanRunDuringConflict;
import org.locationtech.geogig.repository.Repository;
import org.locationtech.geogig.storage.GraphDatabase;
import com.google.common.base.Optional;
import com.google.common.base.Preconditions;
import com.google.common.base.Predicate;
import com.google.common.collect.AbstractIterator;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterators;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
/**
* Operation to query the commits logs.
* <p>
* The list of commits to return can be filtered by setting the following properties:
* <ul>
* <li> {@link #setLimit(int) limit}: Limits the number of commits to return.
* <li> {@link #setTimeRange(Range) timeRange}: return commits that fall in to the given time range.
* <li> {@link #setSince(ObjectId) since}...{@link #setUntil(ObjectId) until}: Show only commits
* between the named two commits.
* <li> {@link #addPath(String) addPath}: Show only commits that affect the specified path.
* </ul>
* </p>
*
*
*/
@CanRunDuringConflict
public class LogOp extends AbstractGeoGigOp<Iterator<RevCommit>> {
private static final Range<Long> ALWAYS = new Range<Long>(Long.class, 0L, true, Long.MAX_VALUE,
true);
private Range<Long> timeRange;
private Integer skip;
private Integer limit;
private ObjectId since;
private ObjectId until;
private Set<String> paths;
private Pattern author;
private Pattern commiter;
private boolean topo;
private boolean firstParent;
private List<ObjectId> commits = Lists.newArrayList();
public LogOp() {
timeRange = ALWAYS;
}
/**
* @param skip sets the number of commits to skip from the commit list
* @return {@code this}
*/
public LogOp setSkip(int skip) {
Preconditions.checkArgument(skip > 0, "skip shall be > 0: " + skip);
this.skip = Integer.valueOf(skip);
return this;
}
/**
* @param limit sets the limit for the amount of commits to show
* @return {@code this}
*/
public LogOp setLimit(int limit) {
Preconditions.checkArgument(limit > 0, "limit shall be > 0: " + limit);
this.limit = Integer.valueOf(limit);
return this;
}
/**
* Indicates to return only commits newer than the given one ({@code since} is exclusive)
*
* @param since the initial (oldest and exclusive) commit id, ({@code null} sets the default)
* @return {@code this}
* @see #setUntil(ObjectId)
*/
public LogOp setSince(final ObjectId since) {
this.since = since;
return this;
}
/**
* Indicates to return commits up to the provided one, inclusive.
*
* @param until the final (newest and inclusive) commit id, ({@code null} sets the default)
* @return {@code this}
* @see #setSince(ObjectId)
*/
public LogOp setUntil(ObjectId until) {
this.until = until;
return this;
}
/**
* Sets whether commits should be ordered not according to its date, but to is structure in the
* history branches
*
* @param topo true if commits should be ordered not according to its date, but to is structure
* in the history branches
* @return {@code this}
*/
public LogOp setTopoOrder(boolean topo) {
this.topo = topo;
return this;
}
/**
* Sets whether the log should list the first parent of each commit
*
* @param firstParent true if it should show only the first parent
* @return {@code this}
*/
public LogOp setFirstParentOnly(boolean firstParent) {
this.firstParent = firstParent;
return this;
}
/**
* Adds a commit to be used as starting point for computing history. If no commit is provided,
* HEAD is used, or the 'until' commit if provided
*
* @param branch the branch to use
* @return {@code this}
*/
public LogOp addCommit(ObjectId commit) {
this.commits.add(commit);
return this;
}
/**
* Sets the regexp to filter out author names
*
* @param the regexp to use for filtering author names
* @return {@code this}
*/
public LogOp setAuthor(String author) {
this.author = Pattern.compile(author);
return this;
}
/**
* Sets the regexp to filter out commiter names
*
* @param the regexp to use for filtering commiter names
* @return {@code this}
*/
public LogOp setCommiter(String commiter) {
this.commiter = Pattern.compile(commiter);
return this;
}
/**
* Show only commits that affect any of the specified paths.
*
* @param path
* @return {@code this}
*/
public LogOp addPath(final String path) {
Preconditions.checkNotNull(path);
if (this.paths == null) {
this.paths = new HashSet<String>();
}
this.paths.add(path);
return this;
}
/**
* Show only commits that lie within the specified time range.
*
* @param commitRange time range to show commits from
* @return {@code this}
*/
public LogOp setTimeRange(final Range<Date> commitRange) {
if (commitRange == null) {
this.timeRange = ALWAYS;
} else {
this.timeRange = new Range<Long>(Long.class, commitRange.getMinValue().getTime(),
commitRange.isMinIncluded(), commitRange.getMaxValue().getTime(),
commitRange.isMaxIncluded());
}
return this;
}
/**
* Executes the log operation.
*
* @return the list of commits that satisfy the query criteria, most recent first.
* @see org.locationtech.geogig.api.AbstractGeoGigOp#call()
*/
@Override
protected Iterator<RevCommit> _call() {
ObjectId newestCommitId;
ObjectId oldestCommitId;
{
if (this.until == null) {
newestCommitId = command(RevParse.class).setRefSpec(Ref.HEAD).call().get();
} else {
if (!repository().commitExists(this.until)) {
throw new IllegalArgumentException(
"Provided 'until' commit id does not exist: " + until.toString());
}
newestCommitId = this.until;
}
if (this.since == null) {
oldestCommitId = ObjectId.NULL;
} else {
if (!repository().commitExists(this.since)) {
throw new IllegalArgumentException(
"Provided 'since' commit id does not exist: " + since.toString());
}
oldestCommitId = this.since;
}
}
Iterator<RevCommit> history;
if (firstParent) {
history = new LinearHistoryIterator(newestCommitId, repository());
} else {
if (commits.isEmpty()) {
commits.add(newestCommitId);
}
if (topo) {
history = new TopologicalHistoryIterator(commits, repository(), graphDatabase());
} else {
history = new ChronologicalHistoryIterator(commits, repository());
}
}
LogFilter filter = new LogFilter(oldestCommitId, timeRange, paths, author, commiter);
Iterator<RevCommit> filteredCommits = Iterators.filter(history, filter);
if (skip != null) {
Iterators.advance(filteredCommits, skip.intValue());
}
if (limit != null) {
filteredCommits = Iterators.limit(filteredCommits, limit.intValue());
}
return filteredCommits;
}
/**
* Iterator that traverses the commit history backwards starting from the provided commit, in
* chronological order. It performs a reverse breadth-first search
*
*/
private static class ChronologicalHistoryIterator extends AbstractIterator<RevCommit> {
private final Repository repo;
private Set<RevCommit> parents;
/**
* Constructs a new {@code LinearHistoryIterator} with the given parameters.
*
* @param tip the first commit in the history
* @param repo the repository where the commits are stored.
*/
public ChronologicalHistoryIterator(final List<ObjectId> tips, final Repository repo) {
parents = Sets.newHashSet();
for (ObjectId tip : tips) {
if (!tip.isNull()) {
final RevCommit commit = repo.getCommit(tip);
parents.add(commit);
}
}
this.repo = repo;
}
/**
* Calculates the next commit in the history.
*
* @return the next {@link RevCommit commit} in the history
*/
@Override
protected RevCommit computeNext() {
if (parents.isEmpty()) {
return endOfData();
} else {
Iterator<RevCommit> iter = parents.iterator();
// TODO: Maybe we should make RevCommit implement Comparable?
RevCommit mostRecent = iter.next();
while (iter.hasNext()) {
RevCommit commit = iter.next();
if (mostRecent.getCommitter().getTimestamp() < commit.getCommitter()
.getTimestamp()) {
mostRecent = commit;
}
}
parents.remove(mostRecent);
RevCommit commit;
for (ObjectId parent : mostRecent.getParentIds()) {
if (repo.commitExists(parent)) {
commit = repo.getCommit(parent);
parents.add(commit);
}
}
return mostRecent;
}
}
}
/**
* Iterator that traverses the commit history backwards starting from the provided commit, in
* topological order. It performs a reverse depth-first search
*
*/
private static class TopologicalHistoryIterator extends AbstractIterator<RevCommit> {
private final Repository repo;
private Stack<RevCommit> tips;
private RevCommit lastCommit;
private List<ObjectId> stopPoints;
private GraphDatabase graphDb;
/**
* Constructs a new {@code LinearHistoryIterator} with the given parameters.
*
* @param tipsList the list of tips to start computing history from
* @param repo the repository where the commits are stored.
* @param graphDb
*/
public TopologicalHistoryIterator(final List<ObjectId> tipsList, final Repository repo,
GraphDatabase graphDb) {
this.graphDb = graphDb;
tips = new Stack<RevCommit>();
stopPoints = Lists.newArrayList();
for (ObjectId tip : tipsList) {
if (!tip.isNull()) {
final RevCommit commit = repo.getCommit(tip);
tips.add(commit);
stopPoints.add(tip);
}
}
this.repo = repo;
}
/**
* Calculates the next commit in the history.
*
* @return the next {@link RevCommit commit} in the history
*/
@Override
protected RevCommit computeNext() {
if (lastCommit == null) {
lastCommit = tips.pop();
return lastCommit;
}
Optional<ObjectId> parent = Optional.absent();
int index = 0;
for (ObjectId parentId : lastCommit.getParentIds()) {
if (repo.commitExists(parentId)) {
parent = Optional.of(parentId);
break;
}
index++;
}
if (!parent.isPresent() || parent.get().isNull() || stopPoints.contains(parent.get())) {
// move to the next tip and start traversing it
if (tips.isEmpty()) {
return endOfData();
} else {
lastCommit = tips.pop();
}
} else {
List<ObjectId> parents = lastCommit.getParentIds();
for (int i = index + 1; i < parents.size(); i++) {
if (repo.commitExists(parents.get(i))) {
final RevCommit commit = repo.getCommit(parents.get(i));
tips.push(commit);
}
}
lastCommit = repo.getCommit(parent.get());
ImmutableList<ObjectId> children = this.graphDb.getChildren(parent.get());
if (children.size() > 1) {
stopPoints.add(parent.get());
}
}
return lastCommit;
}
}
/**
* Iterator that traverses the commit history backwards starting from the provided commit, using
* only the first parent of each commit
*
*/
private static class LinearHistoryIterator extends AbstractIterator<RevCommit> {
private Optional<ObjectId> nextCommitId;
private final Repository repo;
/**
* Constructs a new {@code LinearHistoryIterator} with the given parameters.
*
* @param tip the first commit in the history
* @param repo the repository where the commits are stored.
*/
@SuppressWarnings("unchecked")
public LinearHistoryIterator(final ObjectId tip, final Repository repo) {
this.nextCommitId = (Optional<ObjectId>) (tip.isNull() ? Optional.absent() : Optional
.of(tip));
this.repo = repo;
}
/**
* Calculates the next commit in the history.
*
* @return the next {@link RevCommit commit} in the history
*/
@Override
protected RevCommit computeNext() {
if (nextCommitId.isPresent()) {
RevCommit commit = repo.getCommit(nextCommitId.get());
nextCommitId = commit.parentN(0);
if (nextCommitId.isPresent() && !repo.commitExists(nextCommitId.get())) {
nextCommitId = Optional.absent();
}
return commit;
}
return endOfData();
}
}
/**
* Checks whether the given commit satisfies all the filter criteria set to this op.
*
* @return {@code true} if the commit satisfies the filter criteria set to this op
*/
private class LogFilter implements Predicate<RevCommit> {
private boolean toReached;
private final ObjectId oldestCommitId;
private final Range<Long> timeRange;
private final Set<String> paths;
private Pattern author;
private Pattern committer;
private FindTreeChild findTreeChild;
/**
* Constructs a new {@code LogFilter} with the given parameters.
*
* @param oldestCommitId the oldest commit, exclusive. Indicates when to stop evaluating.
* @param timeRange extra time range filter besides oldest commit
* @param paths extra filter on content, indicates to return only commits that affected any
* of the provided paths
* @param commiter the regexp pattern to filter author names
* @param author the regexp pattern to filter commiter names
*/
public LogFilter(final ObjectId oldestCommitId, final Range<Long> timeRange,
final Set<String> paths, Pattern author, Pattern commiter) {
Preconditions.checkNotNull(oldestCommitId);
Preconditions.checkNotNull(timeRange);
this.oldestCommitId = oldestCommitId;
this.timeRange = timeRange;
this.author = author;
this.committer = commiter;
this.paths = paths;
findTreeChild = command(FindTreeChild.class);
}
/**
* @return {@code true} if the commit satisfies the filter criteria set to this op
* @see com.google.common.base.Predicate#apply(java.lang.Object)
*/
@Override
public boolean apply(final RevCommit commit) {
if (toReached) {
return false;
}
if (oldestCommitId.equals(commit.getId())) {
toReached = true;
return false;
}
Optional<String> authorName = commit.getAuthor().getName();
if (author != null && authorName.isPresent()) {
Matcher authorMatcher = author.matcher(authorName.get());
if (!authorMatcher.matches()) {
return false;
}
}
Optional<String> committerName = commit.getCommitter().getName();
if (committer != null && committerName.isPresent()) {
Matcher committerMatcher = committer.matcher(committerName.get());
if (!committerMatcher.matches()) {
return false;
}
}
boolean applies = timeRange
.contains(Long.valueOf(commit.getCommitter().getTimestamp()));
if (!applies) {
return false;
}
if (paths != null) {
applies = false;
final Repository repository = repository();
// did this commit touch any of the paths?
RevTree commitTree = repository.getTree(commit.getTreeId());
ObjectId currentValue, parentValue;
for (String path : paths) {
currentValue = getPathHash(commitTree, path);
// See if the new value is different from any of the parents.
int parentIndex = 0;
do {
ObjectId parentId = commit.parentN(parentIndex++).or(ObjectId.NULL);
if (parentId.isNull() || !repository.commitExists(parentId)) {
// we have reached the bottom of a shallow clone or the end of history.
if (!currentValue.isNull()) {
applies = true;
break;
}
} else {
RevCommit otherCommit = repository.getCommit(parentId);
RevTree parentTree = repository.getTree(otherCommit.getTreeId());
parentValue = getPathHash(parentTree, path);
if (!parentValue.equals(currentValue)) {
applies = true;
break;
}
}
} while (parentIndex < commit.getParentIds().size());
if (applies) {
break;
}
}
}
return applies;
}
private ObjectId getPathHash(RevTree tree, String path) {
ObjectId hash = ObjectId.NULL;
Optional<NodeRef> ref = findTreeChild.setChildPath(path).setParent(tree).call();
if (ref.isPresent()) {
hash = ref.get().getNode().getObjectId();
}
return hash;
}
}
}