RevertCommand.java

/*
 * Copyright (C) 2010, Christian Halstrick <christian.halstrick@sap.com> and others
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Distribution License v. 1.0 which is available at
 * https://www.eclipse.org/org/documents/edl-v10.php.
 *
 * SPDX-License-Identifier: BSD-3-Clause
 */
package org.eclipse.jgit.api;

import static org.eclipse.jgit.lib.Constants.OBJECT_ID_ABBREV_STRING_LENGTH;

import java.io.IOException;
import java.text.MessageFormat;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;

import org.eclipse.jgit.api.MergeResult.MergeStatus;
import org.eclipse.jgit.api.errors.ConcurrentRefUpdateException;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.api.errors.JGitInternalException;
import org.eclipse.jgit.api.errors.MultipleParentsNotAllowedException;
import org.eclipse.jgit.api.errors.NoHeadException;
import org.eclipse.jgit.api.errors.NoMessageException;
import org.eclipse.jgit.api.errors.UnmergedPathsException;
import org.eclipse.jgit.api.errors.WrongRepositoryStateException;
import org.eclipse.jgit.dircache.DirCacheCheckout;
import org.eclipse.jgit.events.WorkingTreeModifiedEvent;
import org.eclipse.jgit.internal.JGitText;
import org.eclipse.jgit.lib.AnyObjectId;
import org.eclipse.jgit.lib.CommitConfig;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.NullProgressMonitor;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectIdRef;
import org.eclipse.jgit.lib.ProgressMonitor;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Ref.Storage;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.merge.MergeMessageFormatter;
import org.eclipse.jgit.merge.MergeStrategy;
import org.eclipse.jgit.merge.ResolveMerger;
import org.eclipse.jgit.merge.ResolveMerger.MergeFailureReason;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.treewalk.FileTreeIterator;

/**
 * A class used to execute a {@code revert} command. It has setters for all
 * supported options and arguments of this command and a {@link #call()} method
 * to finally execute the command. Each instance of this class should only be
 * used for one invocation of the command (means: one call to {@link #call()})
 *
 * @see <a
 *      href="http://www.kernel.org/pub/software/scm/git/docs/git-revert.html"
 *      >Git documentation about revert</a>
 */
public class RevertCommand extends GitCommand<RevCommit> {
	private List<Ref> commits = new LinkedList<>();

	private String ourCommitName = null;

	private List<Ref> revertedRefs = new LinkedList<>();

	private MergeResult failingResult;

	private List<String> unmergedPaths;

	private MergeStrategy strategy = MergeStrategy.RECURSIVE;

	private ProgressMonitor monitor = NullProgressMonitor.INSTANCE;

	/**
	 * <p>
	 * Constructor for RevertCommand.
	 * </p>
	 *
	 * @param repo
	 *            the {@link org.eclipse.jgit.lib.Repository}
	 */
	protected RevertCommand(Repository repo) {
		super(repo);
	}

	/**
	 * {@inheritDoc}
	 * <p>
	 * Executes the {@code revert} command with all the options and parameters
	 * collected by the setter methods (e.g. {@link #include(Ref)} of this
	 * class. Each instance of this class should only be used for one invocation
	 * of the command. Don't call this method twice on an instance.
	 */
	@Override
	public RevCommit call() throws NoMessageException, UnmergedPathsException,
			ConcurrentRefUpdateException, WrongRepositoryStateException,
			GitAPIException {
		RevCommit newHead = null;
		checkCallable();

		try (RevWalk revWalk = new RevWalk(repo)) {

			// get the head commit
			Ref headRef = repo.exactRef(Constants.HEAD);
			if (headRef == null)
				throw new NoHeadException(
						JGitText.get().commitOnRepoWithoutHEADCurrentlyNotSupported);
			RevCommit headCommit = revWalk.parseCommit(headRef.getObjectId());

			newHead = headCommit;

			// loop through all refs to be reverted
			for (Ref src : commits) {
				// get the commit to be reverted
				// handle annotated tags
				ObjectId srcObjectId = src.getPeeledObjectId();
				if (srcObjectId == null)
					srcObjectId = src.getObjectId();
				RevCommit srcCommit = revWalk.parseCommit(srcObjectId);

				// get the parent of the commit to revert
				if (srcCommit.getParentCount() != 1)
					throw new MultipleParentsNotAllowedException(
							MessageFormat.format(
									JGitText.get().canOnlyRevertCommitsWithOneParent,
									srcCommit.name(),
									Integer.valueOf(srcCommit.getParentCount())));

				RevCommit srcParent = srcCommit.getParent(0);
				revWalk.parseHeaders(srcParent);

				String ourName = calculateOurName(headRef);
				String revertName = srcCommit.getId()
						.abbreviate(OBJECT_ID_ABBREV_STRING_LENGTH).name() + " " //$NON-NLS-1$
						+ srcCommit.getShortMessage();

				ResolveMerger merger = (ResolveMerger) strategy.newMerger(repo);
				merger.setWorkingTreeIterator(new FileTreeIterator(repo));
				merger.setBase(srcCommit.getTree());
				merger.setCommitNames(new String[] {
						"BASE", ourName, revertName }); //$NON-NLS-1$

				String shortMessage = "Revert \"" + srcCommit.getShortMessage() //$NON-NLS-1$
						+ "\""; //$NON-NLS-1$
				String newMessage = shortMessage + "\n\n" //$NON-NLS-1$
						+ "This reverts commit " + srcCommit.getId().getName() //$NON-NLS-1$
						+ ".\n"; //$NON-NLS-1$
				if (merger.merge(headCommit, srcParent)) {
					if (!merger.getModifiedFiles().isEmpty()) {
						repo.fireEvent(new WorkingTreeModifiedEvent(
								merger.getModifiedFiles(), null));
					}
					if (AnyObjectId.isEqual(headCommit.getTree().getId(),
							merger.getResultTreeId()))
						continue;
					DirCacheCheckout dco = new DirCacheCheckout(repo,
							headCommit.getTree(), repo.lockDirCache(),
							merger.getResultTreeId());
					dco.setFailOnConflict(true);
					dco.setProgressMonitor(monitor);
					dco.checkout();
					try (Git git = new Git(getRepository())) {
						newHead = git.commit().setMessage(newMessage)
								.setReflogComment("revert: " + shortMessage) //$NON-NLS-1$
								.call();
					}
					revertedRefs.add(src);
					headCommit = newHead;
				} else {
					unmergedPaths = merger.getUnmergedPaths();
					Map<String, MergeFailureReason> failingPaths = merger
							.getFailingPaths();
					if (failingPaths != null)
						failingResult = new MergeResult(null,
								merger.getBaseCommitId(),
								new ObjectId[] { headCommit.getId(),
										srcParent.getId() },
								MergeStatus.FAILED, strategy,
								merger.getMergeResults(), failingPaths, null);
					else
						failingResult = new MergeResult(null,
								merger.getBaseCommitId(),
								new ObjectId[] { headCommit.getId(),
										srcParent.getId() },
								MergeStatus.CONFLICTING, strategy,
								merger.getMergeResults(), failingPaths, null);
					if (!merger.failed() && !unmergedPaths.isEmpty()) {
						CommitConfig config = repo.getConfig()
								.get(CommitConfig.KEY);
						char commentChar = config.getCommentChar(newMessage);
						String message = new MergeMessageFormatter()
								.formatWithConflicts(newMessage,
										merger.getUnmergedPaths(), commentChar);
						repo.writeRevertHead(srcCommit.getId());
						repo.writeMergeCommitMsg(message);
					}
					return null;
				}
			}
		} catch (IOException e) {
			throw new JGitInternalException(
					MessageFormat.format(
									JGitText.get().exceptionCaughtDuringExecutionOfRevertCommand,
							e), e);
		}
		return newHead;
	}

	/**
	 * Include a {@code Ref} to a commit to be reverted
	 *
	 * @param commit
	 *            a reference to a commit to be reverted into the current head
	 * @return {@code this}
	 */
	public RevertCommand include(Ref commit) {
		checkCallable();
		commits.add(commit);
		return this;
	}

	/**
	 * Include a commit to be reverted
	 *
	 * @param commit
	 *            the Id of a commit to be reverted into the current head
	 * @return {@code this}
	 */
	public RevertCommand include(AnyObjectId commit) {
		return include(commit.getName(), commit);
	}

	/**
	 * Include a commit to be reverted
	 *
	 * @param name
	 *            name of a {@code Ref} referring to the commit
	 * @param commit
	 *            the Id of a commit which is reverted into the current head
	 * @return {@code this}
	 */
	public RevertCommand include(String name, AnyObjectId commit) {
		return include(new ObjectIdRef.Unpeeled(Storage.LOOSE, name,
				commit.copy()));
	}

	/**
	 * Set the name to be used in the "OURS" place for conflict markers
	 *
	 * @param ourCommitName
	 *            the name that should be used in the "OURS" place for conflict
	 *            markers
	 * @return {@code this}
	 */
	public RevertCommand setOurCommitName(String ourCommitName) {
		this.ourCommitName = ourCommitName;
		return this;
	}

	private String calculateOurName(Ref headRef) {
		if (ourCommitName != null)
			return ourCommitName;

		String targetRefName = headRef.getTarget().getName();
		String headName = Repository.shortenRefName(targetRefName);
		return headName;
	}

	/**
	 * Get the list of successfully reverted {@link org.eclipse.jgit.lib.Ref}'s.
	 *
	 * @return the list of successfully reverted
	 *         {@link org.eclipse.jgit.lib.Ref}'s. Never <code>null</code> but
	 *         maybe an empty list if no commit was successfully cherry-picked
	 */
	public List<Ref> getRevertedRefs() {
		return revertedRefs;
	}

	/**
	 * Get the result of a merge failure
	 *
	 * @return the result of a merge failure, <code>null</code> if no merge
	 *         failure occurred during the revert
	 */
	public MergeResult getFailingResult() {
		return failingResult;
	}

	/**
	 * Get unmerged paths
	 *
	 * @return the unmerged paths, will be null if no merge conflicts
	 */
	public List<String> getUnmergedPaths() {
		return unmergedPaths;
	}

	/**
	 * Set the merge strategy to use for this revert command
	 *
	 * @param strategy
	 *            The merge strategy to use for this revert command.
	 * @return {@code this}
	 * @since 3.4
	 */
	public RevertCommand setStrategy(MergeStrategy strategy) {
		this.strategy = strategy;
		return this;
	}

	/**
	 * The progress monitor associated with the revert operation. By default,
	 * this is set to <code>NullProgressMonitor</code>
	 *
	 * @see NullProgressMonitor
	 * @param monitor
	 *            a {@link org.eclipse.jgit.lib.ProgressMonitor}
	 * @return {@code this}
	 * @since 4.11
	 */
	public RevertCommand setProgressMonitor(ProgressMonitor monitor) {
		if (monitor == null) {
			monitor = NullProgressMonitor.INSTANCE;
		}
		this.monitor = monitor;
		return this;
	}
}