/* Copyright (c) 2013-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:
* Victor Olaya (Boundless) - initial implementation
*/
package org.locationtech.geogig.api.porcelain;
import static com.google.common.base.Preconditions.checkState;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import javax.annotation.Nullable;
import org.locationtech.geogig.api.AbstractGeoGigOp;
import org.locationtech.geogig.api.CommitBuilder;
import org.locationtech.geogig.api.ObjectId;
import org.locationtech.geogig.api.Platform;
import org.locationtech.geogig.api.Ref;
import org.locationtech.geogig.api.RevCommit;
import org.locationtech.geogig.api.SymRef;
import org.locationtech.geogig.api.plumbing.FindCommonAncestor;
import org.locationtech.geogig.api.plumbing.ForEachRef;
import org.locationtech.geogig.api.plumbing.RefParse;
import org.locationtech.geogig.api.plumbing.UpdateRef;
import org.locationtech.geogig.api.plumbing.UpdateSymRef;
import org.locationtech.geogig.api.porcelain.ResetOp.ResetMode;
import org.locationtech.geogig.repository.Repository;
import org.locationtech.geogig.storage.GraphDatabase;
import com.google.common.base.Function;
import com.google.common.base.Optional;
import com.google.common.base.Preconditions;
import com.google.common.base.Predicate;
import com.google.common.base.Suppliers;
import com.google.common.collect.Collections2;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
/**
* Operation to squash commits into one.
*/
public class SquashOp extends AbstractGeoGigOp<ObjectId> {
private RevCommit since;
private RevCommit until;
private String message;
/**
* Indicates the first commit to squash. If no message is provided, the message from this commit
* will be used
*
* @param the first (oldest) commit to squash
* @return {@code this}
*/
public SquashOp setSince(final RevCommit since) {
this.since = since;
return this;
}
/**
* Indicates the last commit to squash
*
* @param the last (most recent) commit to squash
* @return {@code this}
*/
public SquashOp setUntil(RevCommit until) {
this.until = until;
return this;
}
/**
* Indicates the message to use for the commit. If null, the message from the 'since' commit
* will be used
*
* @param the message to use for the commit
* @return {@code this}
*/
public SquashOp setMessage(String message) {
this.message = message;
return this;
}
/**
* Executes the squash operation.
*
* @return the new head after modifying the history squashing commits
* @see org.locationtech.geogig.api.AbstractGeoGigOp#call()
*/
@Override
protected ObjectId _call() {
Preconditions.checkNotNull(since);
Preconditions.checkNotNull(until);
GraphDatabase graphDb = graphDatabase();
Repository repository = repository();
Platform platform = platform();
final Optional<Ref> currHead = command(RefParse.class).setName(Ref.HEAD).call();
Preconditions.checkState(currHead.isPresent(), "Repository has no HEAD, can't squash.");
Preconditions.checkState(currHead.get() instanceof SymRef,
"Can't squash from detached HEAD");
final SymRef headRef = (SymRef) currHead.get();
final String currentBranch = headRef.getTarget();
Preconditions.checkState(index().isClean() && workingTree().isClean(),
"You must have a clean working tree and index to perform a squash.");
Optional<ObjectId> ancestor = command(FindCommonAncestor.class).setLeft(since)
.setRight(until).call();
Preconditions.checkArgument(ancestor.isPresent(),
"'since' and 'until' command do not have a common ancestor");
Preconditions.checkArgument(ancestor.get().equals(since.getId()),
"Commits provided in wrong order");
Preconditions.checkArgument(!since.getParentIds().isEmpty(),
"'since' commit has no parents");
// we get a a list of commits to apply on top of the squashed commits
List<RevCommit> commits = getCommitsAfterUntil();
ImmutableSet<Ref> refs = command(ForEachRef.class).setPrefixFilter(Ref.HEADS_PREFIX).call();
// we create a list of all parents of those squashed commits, in case they are
// merge commits. The resulting commit will have all these parents
//
// While iterating the set of commits to squash, we check that there are no branch starting
// points among them. Any commit with more than one child causes an exception to be thrown,
// since the squash operation does not support squashing those commits
Iterator<RevCommit> toSquash = command(LogOp.class).setSince(since.getParentIds().get(0))
.setUntil(until.getId()).setFirstParentOnly(true).call();
List<ObjectId> firstParents = Lists.newArrayList();
List<ObjectId> secondaryParents = Lists.newArrayList();
final List<ObjectId> squashedIds = Lists.newArrayList();
RevCommit commitToSquash = until;
while (toSquash.hasNext()) {
commitToSquash = toSquash.next();
squashedIds.add(commitToSquash.getId());
Preconditions
.checkArgument(
graphDb.getChildren(commitToSquash.getId()).size() < 2,
"The commits to squash include a branch starting point. Squashing that type of commit is not supported.");
for (Ref ref : refs) {
// In case a branch has been created but no commit has been made on it and the
// starting commit has just one child
Preconditions
.checkArgument(
!ref.getObjectId().equals(commitToSquash.getId())
|| ref.getObjectId().equals(currHead.get().getObjectId())
|| commitToSquash.getParentIds().size() > 1,
"The commits to squash include a branch starting point. Squashing that type of commit is not supported.");
}
ImmutableList<ObjectId> parentIds = commitToSquash.getParentIds();
for (int i = 1; i < parentIds.size(); i++) {
secondaryParents.add(parentIds.get(i));
}
firstParents.add(parentIds.get(0));
}
Preconditions.checkArgument(since.equals(commitToSquash),
"Cannot reach 'since' from 'until' commit through first parentage");
// We do the same check in the children commits
for (RevCommit commit : commits) {
Preconditions
.checkArgument(
graphDb.getChildren(commit.getId()).size() < 2,
"The commits after the ones to squash include a branch starting point. This scenario is not supported.");
for (Ref ref : refs) {
// In case a branch has been created but no commit has been made on it
Preconditions
.checkArgument(
!ref.getObjectId().equals(commit.getId())
|| ref.getObjectId().equals(currHead.get().getObjectId())
|| commit.getParentIds().size() > 1,
"The commits after the ones to squash include a branch starting point. This scenario is not supported.");
}
}
ObjectId newHead;
// rewind the head
newHead = since.getParentIds().get(0);
command(ResetOp.class).setCommit(Suppliers.ofInstance(newHead)).setMode(ResetMode.HARD)
.call();
// add the current HEAD as first parent of the resulting commit
// parents.add(0, newHead);
// Create new commit
List<ObjectId> parents = Lists.newArrayList();
parents.addAll(firstParents);
parents.addAll(secondaryParents);
ObjectId endTree = until.getTreeId();
CommitBuilder builder = new CommitBuilder(until);
Collection<ObjectId> filteredParents = Collections2.filter(parents,
new Predicate<ObjectId>() {
@Override
public boolean apply(@Nullable ObjectId id) {
return !squashedIds.contains(id);
}
});
builder.setParentIds(Lists.newArrayList(filteredParents));
builder.setTreeId(endTree);
if (message == null) {
message = since.getMessage();
}
long timestamp = platform.currentTimeMillis();
builder.setMessage(message);
builder.setCommitter(resolveCommitter());
builder.setCommitterEmail(resolveCommitterEmail());
builder.setCommitterTimestamp(timestamp);
builder.setCommitterTimeZoneOffset(platform.timeZoneOffset(timestamp));
builder.setAuthorTimestamp(until.getAuthor().getTimestamp());
RevCommit newCommit = builder.build();
repository.objectDatabase().put(newCommit);
newHead = newCommit.getId();
ObjectId newTreeId = newCommit.getTreeId();
command(UpdateRef.class).setName(currentBranch).setNewValue(newHead).call();
command(UpdateSymRef.class).setName(Ref.HEAD).setNewValue(currentBranch).call();
workingTree().updateWorkHead(newTreeId);
index().updateStageHead(newTreeId);
// now put the other commits after the squashed one
newHead = addCommits(commits, currentBranch, newHead);
return newHead;
}
private ObjectId addCommits(List<RevCommit> commits, String currentBranch,
final ObjectId squashedId) {
final Platform platform = platform();
final Map<ObjectId, ObjectId> replacedCommits = Maps.newHashMap();
replacedCommits.put(until.getId(), squashedId);
ObjectId head = squashedId;
for (RevCommit commit : commits) {
CommitBuilder builder = new CommitBuilder(commit);
Collection<ObjectId> parents = Collections2.transform(commit.getParentIds(),
new Function<ObjectId, ObjectId>() {
@Override
@Nullable
public ObjectId apply(@Nullable ObjectId id) {
if (replacedCommits.containsKey(id)) {
return replacedCommits.get(id);
} else {
return id;
}
}
});
builder.setParentIds(Lists.newArrayList(parents));
builder.setTreeId(commit.getTreeId());
long timestamp = platform.currentTimeMillis();
builder.setCommitterTimestamp(timestamp);
builder.setCommitterTimeZoneOffset(platform.timeZoneOffset(timestamp));
RevCommit newCommit = builder.build();
replacedCommits.put(commit.getId(), newCommit.getId());
objectDatabase().put(newCommit);
head = newCommit.getId();
ObjectId newTreeId = newCommit.getTreeId();
command(UpdateRef.class).setName(currentBranch).setNewValue(head).call();
command(UpdateSymRef.class).setName(Ref.HEAD).setNewValue(currentBranch).call();
workingTree().updateWorkHead(newTreeId);
index().updateStageHead(newTreeId);
}
return head;
}
private List<RevCommit> getCommitsAfterUntil() {
Iterator<RevCommit> commitIterator = command(LogOp.class).setSince(until.getId()).call();
List<RevCommit> commits = Lists.newArrayList(commitIterator);
Collections.reverse(commits);
return commits;
}
private String resolveCommitter() {
final String key = "user.name";
Optional<String> name = command(ConfigGet.class).setName(key).call();
checkState(
name.isPresent(),
"%s not found in config. Use geogig config [--global] %s <your name> to configure it.",
key, key);
return name.get();
}
private String resolveCommitterEmail() {
final String key = "user.email";
Optional<String> email = command(ConfigGet.class).setName(key).call();
checkState(
email.isPresent(),
"%s not found in config. Use geogig config [--global] %s <your email> to configure it.",
key, key);
return email.get();
}
}