/* 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:
* Johnathan Garrett (LMN Solutions) - initial implementation
*/
package org.locationtech.geogig.api.porcelain;
import static com.google.common.base.Preconditions.checkState;
import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
import org.locationtech.geogig.api.AbstractGeoGigOp;
import org.locationtech.geogig.api.CommitBuilder;
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.SymRef;
import org.locationtech.geogig.api.plumbing.DiffTree;
import org.locationtech.geogig.api.plumbing.FindTreeChild;
import org.locationtech.geogig.api.plumbing.RefParse;
import org.locationtech.geogig.api.plumbing.ResolveGeogigDir;
import org.locationtech.geogig.api.plumbing.UpdateRef;
import org.locationtech.geogig.api.plumbing.UpdateSymRef;
import org.locationtech.geogig.api.plumbing.WriteTree2;
import org.locationtech.geogig.api.plumbing.diff.DiffEntry;
import org.locationtech.geogig.api.plumbing.merge.Conflict;
import org.locationtech.geogig.api.plumbing.merge.ConflictsReadOp;
import org.locationtech.geogig.api.plumbing.merge.ConflictsWriteOp;
import org.locationtech.geogig.api.porcelain.ResetOp.ResetMode;
import org.locationtech.geogig.di.CanRunDuringConflict;
import org.locationtech.geogig.repository.Repository;
import com.google.common.base.Charsets;
import com.google.common.base.Optional;
import com.google.common.base.Preconditions;
import com.google.common.base.Supplier;
import com.google.common.base.Suppliers;
import com.google.common.collect.Iterators;
import com.google.common.collect.Lists;
import com.google.common.io.Files;
/**
* Given one or more existing commits, revert the changes that the related patches introduce, and
* record some new commits that record them. This requires your working tree to be clean (no
* modifications from the HEAD commit).
*
*/
@CanRunDuringConflict
public class RevertOp extends AbstractGeoGigOp<Boolean> {
private List<ObjectId> commits;
private boolean createCommit = true;
private String currentBranch;
private ObjectId revertHead;
private boolean abort;
private boolean continueRevert;
/**
* Adds a commit to revert.
*
* @param onto a supplier for the commit id
* @return {@code this}
*/
public RevertOp addCommit(final Supplier<ObjectId> commit) {
Preconditions.checkNotNull(commit);
if (this.commits == null) {
this.commits = new ArrayList<ObjectId>();
}
this.commits.add(commit.get());
return this;
}
/**
* Sets whether to abort the current revert operation
*
* @param abort
* @return
*/
public RevertOp setAbort(boolean abort) {
this.abort = abort;
return this;
}
/**
* Sets whether to continue a revert operation aborted due to conflicts
*
* @param continueRevert
* @return {@code this}
*/
public RevertOp setContinue(boolean continueRevert) {
this.continueRevert = continueRevert;
return this;
}
/**
* If true, creates a new commit with the changes from the reverted commit. Otherwise, it just
* adds the corresponding changes from the reverted commit to the index and working tree, but
* does not commit anything
*
* @param createCommit whether to create a commit with reverted changes or not.
* @return {@code this}
*/
public RevertOp setCreateCommit(boolean createCommit) {
this.createCommit = createCommit;
return this;
}
/**
* Executes the revert operation.
*
* @return always {@code true}
*/
@Override
protected Boolean _call() {
final Optional<Ref> currHead = command(RefParse.class).setName(Ref.HEAD).call();
Preconditions.checkState(currHead.isPresent(), "Repository has no HEAD, can't revert.");
Preconditions.checkState(currHead.get() instanceof SymRef,
"Can't revert from detached HEAD");
final SymRef headRef = (SymRef) currHead.get();
Preconditions.checkState(!headRef.getObjectId().equals(ObjectId.NULL),
"HEAD has no history.");
currentBranch = headRef.getTarget();
revertHead = currHead.get().getObjectId();
Preconditions.checkArgument(!(continueRevert && abort),
"Cannot continue and abort at the same time");
// count staged and unstaged changes
long staged = index().countStaged(null).count();
long unstaged = workingTree().countUnstaged(null).count();
Preconditions.checkState((staged == 0 && unstaged == 0) || abort || continueRevert,
"You must have a clean working tree and index to perform a revert.");
getProgressListener().started();
// Revert can only be run in a conflicted situation if the abort option is used
List<Conflict> conflicts = command(ConflictsReadOp.class).call();
Preconditions.checkState(conflicts.isEmpty() || abort,
"Cannot run operation while merge conflicts exist.");
Optional<Ref> ref = command(RefParse.class).setName(Ref.ORIG_HEAD).call();
if (abort) {
Preconditions.checkState(ref.isPresent(),
"Cannot abort. You are not in the middle of a revert process.");
command(ResetOp.class).setMode(ResetMode.HARD)
.setCommit(Suppliers.ofInstance(ref.get().getObjectId())).call();
command(UpdateRef.class).setDelete(true).setName(Ref.ORIG_HEAD).call();
return true;
} else if (continueRevert) {
Preconditions.checkState(ref.isPresent(),
"Cannot continue. You are not in the middle of a revert process.");
// Commit the manually-merged changes with the info of the commit that caused the
// conflict
applyNextCommit(false);
// Commit files should already be prepared, so we do nothing else
} else {
Preconditions
.checkState(!ref.isPresent(),
"You are currently in the middle of a merge or rebase operation <ORIG_HEAD is present>.");
getProgressListener().started();
command(UpdateRef.class).setName(Ref.ORIG_HEAD)
.setNewValue(currHead.get().getObjectId()).call();
// Here we prepare the files with the info about the commits to apply in reverse
List<RevCommit> commitsToRevert = Lists.newArrayList();
Repository repository = repository();
for (ObjectId id : commits) {
Preconditions.checkArgument(repository.commitExists(id),
"Commit was not found in the repository: " + id.toString());
RevCommit commit = repository.getCommit(id);
commitsToRevert.add(commit);
}
createRevertCommitsInfoFiles(commitsToRevert);
}
boolean ret;
do {
ret = applyNextCommit(true);
} while (ret);
command(UpdateRef.class).setDelete(true).setName(Ref.ORIG_HEAD).call();
getProgressListener().complete();
return true;
}
private File getRevertFolder() {
URL dir = command(ResolveGeogigDir.class).call().get();
File revertFolder = new File(dir.getFile(), "revert");
if (!revertFolder.exists()) {
Preconditions.checkState(revertFolder.mkdirs(), "Cannot create 'revert' folder");
}
return revertFolder;
}
private void createRevertCommitsInfoFiles(List<RevCommit> commitsToRebase) {
File rebaseFolder = getRevertFolder();
for (int i = 0; i < commitsToRebase.size(); i++) {
File file = new File(rebaseFolder, Integer.toString(i + 1));
try {
Files.write(commitsToRebase.get(i).getId().toString(), file, Charsets.UTF_8);
} catch (IOException e) {
throw new IllegalStateException("Cannot create revert commits info files");
}
}
File nextFile = new File(rebaseFolder, "next");
try {
Files.write("1", nextFile, Charsets.UTF_8);
} catch (IOException e) {
throw new IllegalStateException("Cannot create next revert commit info file");
}
}
private boolean applyNextCommit(boolean useCommitChanges) {
File rebaseFolder = getRevertFolder();
File nextFile = new File(rebaseFolder, "next");
Repository repository = repository();
try {
String idx = Files.readFirstLine(nextFile, Charsets.UTF_8);
File commitFile = new File(rebaseFolder, idx);
if (commitFile.exists()) {
String commitId = Files.readFirstLine(commitFile, Charsets.UTF_8);
RevCommit commit = repository.getCommit(ObjectId.valueOf(commitId));
List<Conflict> conflicts = Lists.newArrayList();
if (useCommitChanges) {
conflicts = applyRevertedChanges(commit);
}
if (createCommit && conflicts.isEmpty()) {
createCommit(commit);
} else {
workingTree().updateWorkHead(repository.index().getTree().getId());
if (!conflicts.isEmpty()) {
// mark conflicted elements
command(ConflictsWriteOp.class).setConflicts(conflicts).call();
// created exception message
StringBuilder msg = new StringBuilder();
msg.append("error: could not apply ");
msg.append(commit.getId().toString().substring(0, 7));
msg.append(" " + commit.getMessage() + "\n");
for (Conflict conflict : conflicts) {
msg.append("CONFLICT: conflict in " + conflict.getPath() + "\n");
}
throw new RevertConflictsException(msg.toString());
}
}
commitFile.delete();
int newIdx = Integer.parseInt(idx) + 1;
Files.write(Integer.toString(newIdx), nextFile, Charsets.UTF_8);
return true;
} else {
return false;
}
} catch (IOException e) {
throw new IllegalStateException("Cannot read/write revert commits index file");
}
}
private List<Conflict> applyRevertedChanges(RevCommit commit) {
ObjectId parentCommitId = ObjectId.NULL;
if (commit.getParentIds().size() > 0) {
parentCommitId = commit.getParentIds().get(0);
}
ObjectId parentTreeId = ObjectId.NULL;
Repository repository = repository();
if (repository.commitExists(parentCommitId)) {
parentTreeId = repository.getCommit(parentCommitId).getTreeId();
}
// get changes (in reverse)
Iterator<DiffEntry> reverseDiff = command(DiffTree.class).setNewTree(parentTreeId)
.setOldTree(commit.getTreeId()).setReportTrees(false).call();
ObjectId headTreeId = repository.getCommit(revertHead).getTreeId();
final RevTree headTree = repository.getTree(headTreeId);
ArrayList<Conflict> conflicts = new ArrayList<Conflict>();
DiffEntry diff;
while (reverseDiff.hasNext()) {
diff = reverseDiff.next();
if (diff.isAdd()) {
// Feature was deleted
Optional<NodeRef> node = command(FindTreeChild.class).setChildPath(diff.newPath())
.setIndex(true).setParent(headTree).call();
// make sure it is still deleted
if (node.isPresent()) {
conflicts.add(new Conflict(diff.newPath(), diff.oldObjectId(), node.get()
.objectId(), diff.newObjectId()));
} else {
index().stage(getProgressListener(), Iterators.singletonIterator(diff), 1);
}
} else {
// Feature was added or modified
Optional<NodeRef> node = command(FindTreeChild.class).setChildPath(diff.oldPath())
.setIndex(true).setParent(headTree).call();
ObjectId nodeId = node.get().getNode().getObjectId();
// Make sure it wasn't changed
if (node.isPresent() && nodeId.equals(diff.oldObjectId())) {
index().stage(getProgressListener(), Iterators.singletonIterator(diff), 1);
} else {
// do not mark as conflict if reverting to the same feature currently in HEAD
if (!nodeId.equals(diff.newObjectId())) {
conflicts.add(new Conflict(diff.newPath(), diff.oldObjectId(), node.get()
.objectId(), diff.newObjectId()));
}
}
}
}
return conflicts;
}
private void createCommit(RevCommit commit) {
// write new tree
ObjectId newTreeId = command(WriteTree2.class).call();
long timestamp = platform().currentTimeMillis();
String committerName = resolveCommitter();
String committerEmail = resolveCommitterEmail();
// Create new commit
CommitBuilder builder = new CommitBuilder();
builder.setParentIds(Arrays.asList(revertHead));
builder.setTreeId(newTreeId);
builder.setCommitterTimestamp(timestamp);
builder.setMessage("Revert '" + commit.getMessage() + "'\nThis reverts "
+ commit.getId().toString());
builder.setCommitter(committerName);
builder.setCommitterEmail(committerEmail);
builder.setAuthor(committerName);
builder.setAuthorEmail(committerEmail);
RevCommit newCommit = builder.build();
objectDatabase().put(newCommit);
revertHead = newCommit.getId();
command(UpdateRef.class).setName(currentBranch).setNewValue(revertHead).call();
command(UpdateSymRef.class).setName(Ref.HEAD).setNewValue(currentBranch).call();
workingTree().updateWorkHead(newTreeId);
index().updateStageHead(newTreeId);
}
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();
}
}