/* 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 static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import java.util.Collection;
import java.util.Date;
import java.util.LinkedList;
import java.util.List;
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.Ref;
import org.locationtech.geogig.api.RevCommit;
import org.locationtech.geogig.api.RevPerson;
import org.locationtech.geogig.api.RevTree;
import org.locationtech.geogig.api.SymRef;
import org.locationtech.geogig.api.hooks.Hookable;
import org.locationtech.geogig.api.plumbing.RefParse;
import org.locationtech.geogig.api.plumbing.ResolveTreeish;
import org.locationtech.geogig.api.plumbing.RevObjectParse;
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.merge.ReadMergeCommitMessageOp;
import org.locationtech.geogig.storage.ObjectDatabase;
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.Lists;
/**
* Commits the staged changed in the index to the repository, creating a new commit pointing to the
* new root tree resulting from moving the staged changes to the repository, and updating the HEAD
* ref to the new commit object.
* <p>
* Like {@code git commit -a}, If the {@link #setAll(boolean) all} flag is set, first stages all the
* changed objects in the index, but does not state newly created (unstaged) objects that are not
* already staged.
* </p>
*
*/
@Hookable(name = "commit")
public class CommitOp extends AbstractGeoGigOp<RevCommit> {
private Optional<String> authorName;
private Optional<String> authorEmail;
private String message;
private Long authorTimeStamp;
private Long committerTimeStamp;
private Integer committerTimeZoneOffset;
private Integer authorTimeZoneOffset;
private List<ObjectId> parents = new LinkedList<ObjectId>();
// like the -a option in git commit
private boolean all;
private boolean allowEmpty;
private String committerName;
private String committerEmail;
private RevCommit commit;
private boolean amend;
private final List<String> pathFilters = Lists.newLinkedList();
/**
* If set, ignores other information for creating a commit and uses the passed one.
*
* @param commit the commit to use
*
* @return {@code this}
*/
public CommitOp setCommit(final RevCommit commit) {
this.commit = commit;
return this;
}
/**
* If set, overrides the author's name from the configuration
*
* @param authorName the author's name
* @param authorEmail the author's email
* @return {@code this}
*/
public CommitOp setAuthor(final @Nullable String authorName, @Nullable final String authorEmail) {
this.authorName = Optional.fromNullable(authorName);
this.authorEmail = Optional.fromNullable(authorEmail);
return this;
}
/**
* If set, overrides the committer's name from the configuration
*
* @param committerName the committer's name
* @param committerEmail the committer's email
*/
public CommitOp setCommitter(String committerName, @Nullable String committerEmail) {
checkNotNull(committerName);
this.committerName = committerName;
this.committerEmail = committerEmail;
return this;
}
/**
* Sets the {@link RevCommit#getMessage() commit message}.
*
* @param message description of the changes to record the commit with.
* @return {@code this}, to ease command chaining
*/
public CommitOp setMessage(@Nullable final String message) {
this.message = message;
return this;
}
/**
* Sets the {@link RevPerson#getTimestamp() timestamp} for the author of this commit, or if not
* set defaults to the current system time at the time {@link #call()} is called.
*
* @param timestamp commit timestamp, in milliseconds, as in {@link Date#getTime()}
* @return {@code this}, to ease command chaining
*/
public CommitOp setAuthorTimestamp(@Nullable final Long timestamp) {
this.authorTimeStamp = timestamp;
return this;
}
/**
* Sets the {@link RevPerson#getTimestamp() timestamp} for the committer of this commit, or if
* not set defaults to the current system time at the time {@link #call()} is called.
*
* @param timestamp commit timestamp, in milliseconds, as in {@link Date#getTime()}
* @return {@code this}, to ease command chaining
*/
public CommitOp setCommitterTimestamp(@Nullable final Long timestamp) {
this.committerTimeStamp = timestamp;
return this;
}
/**
* Sets the time zone offset of the author.
*
* @param timeZoneOffset time zone offset of the author
* @return {@code this}, to ease command chaining
*/
public CommitOp setAuthorTimeZoneOffset(@Nullable final Integer timeZoneOffset) {
this.authorTimeZoneOffset = timeZoneOffset;
return this;
}
/**
* Sets the time zone offset of the committer.
*
* @param timeZoneOffset time zone offset of the committer
* @return {@code this}, to ease command chaining
*/
public CommitOp setCommitterTimeZoneOffset(@Nullable final Integer timeZoneOffset) {
this.committerTimeZoneOffset = timeZoneOffset;
return this;
}
/**
* If {@code true}, tells {@link #call()} to stage all the unstaged changes that are not new
* object before performing the commit.
*
* @param all {@code true} to stage changes before commit, {@code false} to not do that.
* Defaults to {@code false}.
* @return {@code this}, to ease command chaining
*/
public CommitOp setAll(boolean all) {
this.all = all;
return this;
}
public CommitOp setPathFilters(@Nullable List<String> pathFilters) {
this.pathFilters.clear();
if (pathFilters != null) {
this.pathFilters.addAll(pathFilters);
}
return this;
}
/**
* @param parents parents to add
* @return {@code this}
*/
public CommitOp addParents(Collection<ObjectId> parents) {
this.parents.addAll(parents);
return this;
}
/**
* Sets whether the operation should ammend the last commit instead of creating a new one
*
* @param amend
* @return
*/
public CommitOp setAmend(boolean amend) {
this.amend = amend;
return this;
}
/**
* Executes the commit operation.
*
* @return the commit just applied, or {@code null} if
* {@code getProgressListener().isCanceled()}
* @see org.locationtech.geogig.api.AbstractGeoGigOp#call()
* @throws NothingToCommitException if there are no staged changes by comparing the index
* staging tree and the repository HEAD tree.
*/
@Override
protected RevCommit _call() throws RuntimeException {
final String committer = resolveCommitter();
final String committerEmail = resolveCommitterEmail();
final String author = resolveAuthor();
final String authorEmail = resolveAuthorEmail();
final Long authorTime = getAuthorTimeStamp();
final Long committerTime = getCommitterTimeStamp();
final Integer authorTimeZoneOffset = getAuthorTimeZoneOffset();
final Integer committerTimeZoneOffset = getCommitterTimeZoneOffset();
getProgressListener().started();
float writeTreeProgress = 99f;
if (all) {
writeTreeProgress = 50f;
AddOp op = command(AddOp.class);
for (String st : pathFilters) {
op.addPattern(st);
}
op.setUpdateOnly(true).setProgressListener(subProgress(49f)).call();
}
if (getProgressListener().isCanceled()) {
return null;
}
final Optional<Ref> currHead = command(RefParse.class).setName(Ref.HEAD).call();
checkState(currHead.isPresent(), "Repository has no HEAD, can't commit");
final Ref headRef = currHead.get();
checkState(headRef instanceof SymRef,//
"HEAD is in a dettached state, cannot commit. Create a branch from it before committing");
final String currentBranch = ((SymRef) headRef).getTarget();
final ObjectId currHeadCommitId = headRef.getObjectId();
Supplier<RevTree> oldRoot = resolveOldRoot();
if (!currHeadCommitId.isNull()) {
if (amend) {
RevCommit headCommit = command(RevObjectParse.class).setObjectId(currHeadCommitId)
.call(RevCommit.class).get();
parents.addAll(headCommit.getParentIds());
if (message == null || message.isEmpty()) {
message = headCommit.getMessage();
}
RevTree commitTree = command(RevObjectParse.class)
.setObjectId(headCommit.getTreeId()).call(RevTree.class).get();
oldRoot = Suppliers.ofInstance(commitTree);
} else {
parents.add(0, currHeadCommitId);
}
} else {
Preconditions.checkArgument(!amend,
"Cannot amend. There is no previous commit to amend");
}
// additional operations in case we are committing after a conflicted merge
final Optional<Ref> mergeHead = command(RefParse.class).setName(Ref.MERGE_HEAD).call();
if (mergeHead.isPresent()) {
ObjectId mergeCommitId = mergeHead.get().getObjectId();
if (!mergeCommitId.isNull()) {
parents.add(mergeCommitId);
}
if (message == null) {
message = command(ReadMergeCommitMessageOp.class).call();
}
}
ObjectId newTreeId;
{
WriteTree2 writeTree = command(WriteTree2.class);
writeTree.setOldRoot(oldRoot).setProgressListener(subProgress(writeTreeProgress));
if (!pathFilters.isEmpty()) {
writeTree.setPathFilter(pathFilters);
}
newTreeId = writeTree.call();
}
if (getProgressListener().isCanceled()) {
return null;
}
final ObjectId currentRootTreeId = command(ResolveTreeish.class)
.setTreeish(currHeadCommitId).call().or(RevTree.EMPTY_TREE_ID);
if (currentRootTreeId.equals(newTreeId)) {
if (!allowEmpty) {
throw new NothingToCommitException("Nothing to commit after " + currHeadCommitId);
}
}
final RevCommit commit;
if (this.commit == null) {
CommitBuilder cb = new CommitBuilder();
cb.setAuthor(author);
cb.setAuthorEmail(authorEmail);
cb.setCommitter(committer);
cb.setCommitterEmail(committerEmail);
cb.setMessage(message);
cb.setParentIds(parents);
cb.setTreeId(newTreeId);
cb.setCommitterTimestamp(committerTime);
cb.setAuthorTimestamp(authorTime);
cb.setCommitterTimeZoneOffset(committerTimeZoneOffset);
cb.setAuthorTimeZoneOffset(authorTimeZoneOffset);
commit = cb.build();
} else {
CommitBuilder cb = new CommitBuilder(this.commit);
cb.setParentIds(parents);
cb.setTreeId(newTreeId);
cb.setCommitterTimestamp(committerTime);
cb.setCommitterTimeZoneOffset(committerTimeZoneOffset);
if (message != null) {
cb.setMessage(message);
}
commit = cb.build();
}
if (getProgressListener().isCanceled()) {
return null;
}
final ObjectDatabase objectDb = objectDatabase();
objectDb.put(commit);
// set the HEAD pointing to the new commit
final Optional<Ref> branchHead = command(UpdateRef.class).setName(currentBranch)
.setNewValue(commit.getId()).call();
checkState(commit.getId().equals(branchHead.get().getObjectId()));
final Optional<Ref> newHead = command(UpdateSymRef.class).setName(Ref.HEAD)
.setNewValue(currentBranch).call();
checkState(currentBranch.equals(((SymRef) newHead.get()).getTarget()));
Optional<ObjectId> treeId = command(ResolveTreeish.class).setTreeish(
branchHead.get().getObjectId()).call();
checkState(treeId.isPresent());
checkState(newTreeId.equals(treeId.get()));
getProgressListener().setProgress(100f);
getProgressListener().complete();
// TODO: maybe all this "heads cleaning" should be put in an independent operation
if (mergeHead.isPresent()) {
command(UpdateRef.class).setDelete(true).setName(Ref.MERGE_HEAD).call();
command(UpdateRef.class).setDelete(true).setName(Ref.ORIG_HEAD).call();
}
final Optional<Ref> cherrypickHead = command(RefParse.class).setName(Ref.CHERRY_PICK_HEAD)
.call();
if (cherrypickHead.isPresent()) {
command(UpdateRef.class).setDelete(true).setName(Ref.CHERRY_PICK_HEAD).call();
command(UpdateRef.class).setDelete(true).setName(Ref.ORIG_HEAD).call();
}
return commit;
}
private Supplier<RevTree> resolveOldRoot() {
Supplier<RevTree> supplier = new Supplier<RevTree>() {
@Override
public RevTree get() {
Optional<ObjectId> head = command(ResolveTreeish.class).setTreeish(Ref.HEAD).call();
if (!head.isPresent() || head.get().isNull()) {
return RevTree.EMPTY;
}
return command(RevObjectParse.class).setObjectId(head.get()).call(RevTree.class)
.get();
}
};
return Suppliers.memoize(supplier);
}
/**
* @return the timestamp to be used for the committer
*/
public long getCommitterTimeStamp() {
if (committerTimeStamp == null) {
committerTimeStamp = platform().currentTimeMillis();
}
return committerTimeStamp.longValue();
}
/**
* @return the time zone offset to be used for the committer
*/
public int getCommitterTimeZoneOffset() {
if (committerTimeZoneOffset == null) {
committerTimeZoneOffset = platform().timeZoneOffset(getCommitterTimeStamp());
}
return committerTimeZoneOffset.intValue();
}
/**
* @return the timestamp to be used for the author
*/
public long getAuthorTimeStamp() {
return authorTimeStamp == null ? getCommitterTimeStamp() : authorTimeStamp;
}
/**
* @return the time zone offset to be used for the committer
*/
public int getAuthorTimeZoneOffset() {
if (authorTimeZoneOffset == null) {
authorTimeZoneOffset = getCommitterTimeZoneOffset();
}
return authorTimeZoneOffset.intValue();
}
private String resolveCommitter() {
if (committerName != null) {
return committerName;
}
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() {
if (committerEmail != null) {
return committerEmail;
}
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();
}
private String resolveAuthor() {
return authorName == null ? resolveCommitter() : authorName.orNull();
}
private String resolveAuthorEmail() {
// only use provided authorEmail if authorName was provided
return authorName == null ? resolveCommitterEmail() : authorEmail.orNull();
}
/**
* @param allowEmptyCommit whether to allow a commit that represents no changes over its parent
* @return {@code this}
*/
public CommitOp setAllowEmpty(boolean allowEmptyCommit) {
this.allowEmpty = allowEmptyCommit;
return this;
}
}