blob: 033b1fd106fcdab750110e439450babad40ca30d [file] [log] [blame]
/*
* Copyright 2000-2010 JetBrains s.r.o.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package git4idea.cherrypick;
import com.intellij.notification.Notification;
import com.intellij.notification.NotificationListener;
import com.intellij.openapi.application.ModalityState;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.vcs.FilePath;
import com.intellij.openapi.vcs.VcsNotifier;
import com.intellij.openapi.vcs.changes.*;
import com.intellij.openapi.vcs.history.VcsRevisionNumber;
import com.intellij.openapi.vcs.merge.MergeDialogCustomizer;
import com.intellij.openapi.vfs.VfsUtil;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.util.ArrayUtil;
import com.intellij.util.Consumer;
import com.intellij.util.Function;
import com.intellij.util.containers.ContainerUtil;
import com.intellij.vcs.log.VcsFullCommitDetails;
import git4idea.GitPlatformFacade;
import git4idea.GitUtil;
import git4idea.commands.Git;
import git4idea.commands.GitCommandResult;
import git4idea.commands.GitSimpleEventDetector;
import git4idea.commands.GitUntrackedFilesOverwrittenByOperationDetector;
import git4idea.merge.GitConflictResolver;
import git4idea.repo.GitRepository;
import git4idea.util.UntrackedFilesNotifier;
import org.jetbrains.annotations.NotNull;
import javax.swing.event.HyperlinkEvent;
import java.io.File;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import static com.intellij.openapi.util.text.StringUtil.pluralize;
import static git4idea.commands.GitSimpleEventDetector.Event.CHERRY_PICK_CONFLICT;
import static git4idea.commands.GitSimpleEventDetector.Event.LOCAL_CHANGES_OVERWRITTEN_BY_CHERRY_PICK;
public class GitCherryPicker {
/**
* Name of the {@code .git/CHERRY_PICK_HEAD} file which is stored under {@code .git} when cherry-pick is in progress,
* and contains the hash of the commit being cherry-picked.
*/
private static final String CHERRY_PICK_HEAD_FILE = "CHERRY_PICK_HEAD";
private static final Logger LOG = Logger.getInstance(GitCherryPicker.class);
@NotNull private final Project myProject;
@NotNull private final Git myGit;
@NotNull private final GitPlatformFacade myPlatformFacade;
@NotNull private final ChangeListManager myChangeListManager;
private final boolean myAutoCommit;
public GitCherryPicker(@NotNull Project project, @NotNull Git git, @NotNull GitPlatformFacade platformFacade, boolean autoCommit) {
myProject = project;
myGit = git;
myPlatformFacade = platformFacade;
myAutoCommit = autoCommit;
myChangeListManager = myPlatformFacade.getChangeListManager(myProject);
}
public void cherryPick(@NotNull Map<GitRepository, List<VcsFullCommitDetails>> commitsInRoots) {
List<GitCommitWrapper> successfulCommits = new ArrayList<GitCommitWrapper>();
GitUtil.workingTreeChangeStarted(myProject);
try {
for (Map.Entry<GitRepository, List<VcsFullCommitDetails>> entry : commitsInRoots.entrySet()) {
GitRepository repository = entry.getKey();
boolean result = cherryPick(repository, entry.getValue(), successfulCommits);
repository.update();
if (!result) {
return;
}
}
notifySuccess(successfulCommits);
}
finally {
GitUtil.workingTreeChangeFinished(myProject);
}
}
// return true to continue with other roots, false to break execution
private boolean cherryPick(@NotNull GitRepository repository, @NotNull List<VcsFullCommitDetails> commits,
@NotNull List<GitCommitWrapper> successfulCommits) {
for (VcsFullCommitDetails commit : commits) {
GitSimpleEventDetector conflictDetector = new GitSimpleEventDetector(CHERRY_PICK_CONFLICT);
GitSimpleEventDetector localChangesOverwrittenDetector = new GitSimpleEventDetector(LOCAL_CHANGES_OVERWRITTEN_BY_CHERRY_PICK);
GitUntrackedFilesOverwrittenByOperationDetector untrackedFilesDetector =
new GitUntrackedFilesOverwrittenByOperationDetector(repository.getRoot());
GitCommandResult result = myGit.cherryPick(repository, commit.getId().asString(), myAutoCommit,
conflictDetector, localChangesOverwrittenDetector, untrackedFilesDetector);
GitCommitWrapper commitWrapper = new GitCommitWrapper(commit);
if (result.success()) {
if (myAutoCommit) {
successfulCommits.add(commitWrapper);
}
else {
boolean committed = updateChangeListManagerShowCommitDialogAndRemoveChangeListOnSuccess(repository, commitWrapper,
successfulCommits);
if (!committed) {
notifyCommitCancelled(commitWrapper, successfulCommits);
return false;
}
}
}
else if (conflictDetector.hasHappened()) {
boolean mergeCompleted = new CherryPickConflictResolver(myProject, myGit, myPlatformFacade, repository.getRoot(),
commit.getId().asString(), commit.getAuthor().getName(),
commit.getSubject()).merge();
if (mergeCompleted) {
boolean committed = updateChangeListManagerShowCommitDialogAndRemoveChangeListOnSuccess(repository, commitWrapper,
successfulCommits);
if (!committed) {
notifyCommitCancelled(commitWrapper, successfulCommits);
return false;
}
}
else {
updateChangeListManager(commit);
notifyConflictWarning(repository, commitWrapper, successfulCommits);
return false;
}
}
else if (untrackedFilesDetector.wasMessageDetected()) {
String description = commitDetails(commitWrapper)
+ "<br/>Some untracked working tree files would be overwritten by cherry-pick.<br/>" +
"Please move, remove or add them before you can cherry-pick. <a href='view'>View them</a>";
description += getSuccessfulCommitDetailsIfAny(successfulCommits);
UntrackedFilesNotifier.notifyUntrackedFilesOverwrittenBy(myProject, repository.getRoot(),
untrackedFilesDetector.getRelativeFilePaths(), "cherry-pick", description);
return false;
}
else if (localChangesOverwrittenDetector.hasHappened()) {
notifyError("Your local changes would be overwritten by cherry-pick.<br/>Commit your changes or stash them to proceed.",
commitWrapper, successfulCommits);
return false;
}
else {
notifyError(result.getErrorOutputAsHtmlString(), commitWrapper, successfulCommits);
return false;
}
}
return true;
}
private boolean updateChangeListManagerShowCommitDialogAndRemoveChangeListOnSuccess(@NotNull GitRepository repository,
@NotNull GitCommitWrapper commit,
@NotNull List<GitCommitWrapper> successfulCommits) {
CherryPickData data = updateChangeListManager(commit.getCommit());
boolean committed = showCommitDialogAndWaitForCommit(repository, commit, data.myChangeList, data.myCommitMessage);
if (committed) {
removeChangeList(data);
successfulCommits.add(commit);
return true;
}
return false;
}
private void removeChangeList(CherryPickData list) {
myChangeListManager.setDefaultChangeList(list.myPreviouslyDefaultChangeList);
if (!myChangeListManager.getDefaultChangeList().equals(list.myChangeList)) {
myChangeListManager.removeChangeList(list.myChangeList);
}
}
private void notifyConflictWarning(@NotNull GitRepository repository, @NotNull GitCommitWrapper commit,
@NotNull List<GitCommitWrapper> successfulCommits) {
NotificationListener resolveLinkListener = new ResolveLinkListener(myProject, myGit, myPlatformFacade, repository.getRoot(),
commit.getCommit().getId().toShortString(),
commit.getCommit().getAuthor().getName(),
commit.getSubject());
String description = commitDetails(commit)
+ "<br/>Unresolved conflicts remain in the working tree. <a href='resolve'>Resolve them.<a/>";
description += getSuccessfulCommitDetailsIfAny(successfulCommits);
VcsNotifier.getInstance(myProject).notifyImportantWarning("Cherry-picked with conflicts", description, resolveLinkListener);
}
private void notifyCommitCancelled(@NotNull GitCommitWrapper commit, @NotNull List<GitCommitWrapper> successfulCommits) {
if (successfulCommits.isEmpty()) {
// don't notify about cancelled commit. Notify just in the case when there were already successful commits in the queue.
return;
}
String description = commitDetails(commit);
description += getSuccessfulCommitDetailsIfAny(successfulCommits);
VcsNotifier.getInstance(myProject).notifyMinorWarning("Cherry-pick cancelled", description, null);
}
private CherryPickData updateChangeListManager(@NotNull final VcsFullCommitDetails commit) {
final Collection<FilePath> paths = ChangesUtil.getPaths(commit.getChanges());
refreshChangedFiles(paths);
final String commitMessage = createCommitMessage(commit);
LocalChangeList previouslyDefaultChangeList = myChangeListManager.getDefaultChangeList();
LocalChangeList changeList = createChangeListAfterUpdate(commit, paths, commitMessage);
return new CherryPickData(changeList, commitMessage, previouslyDefaultChangeList);
}
@NotNull
private LocalChangeList createChangeListAfterUpdate(@NotNull final VcsFullCommitDetails commit, @NotNull final Collection<FilePath> paths,
@NotNull final String commitMessage) {
final AtomicReference<LocalChangeList> changeList = new AtomicReference<LocalChangeList>();
myPlatformFacade.invokeAndWait(new Runnable() {
@Override
public void run() {
myChangeListManager.invokeAfterUpdate(new Runnable() {
public void run() {
changeList.set(createChangeList(commit, commitMessage));
}
}, InvokeAfterUpdateMode.SYNCHRONOUS_NOT_CANCELLABLE, "Cherry-pick",
new Consumer<VcsDirtyScopeManager>() {
public void consume(VcsDirtyScopeManager vcsDirtyScopeManager) {
vcsDirtyScopeManager.filePathsDirty(paths, null);
}
}, ModalityState.NON_MODAL
);
}
}, ModalityState.NON_MODAL);
return changeList.get();
}
@NotNull
private static String createCommitMessage(@NotNull VcsFullCommitDetails commit) {
return commit.getFullMessage() + "\n(cherry picked from commit " + commit.getId().toShortString() + ")";
}
private boolean showCommitDialogAndWaitForCommit(@NotNull final GitRepository repository, @NotNull final GitCommitWrapper commit,
@NotNull final LocalChangeList changeList, @NotNull final String commitMessage) {
final AtomicBoolean commitSucceeded = new AtomicBoolean();
final Semaphore sem = new Semaphore(0);
myPlatformFacade.invokeAndWait(new Runnable() {
@Override
public void run() {
try {
cancelCherryPick(repository);
Collection<Change> changes = commit.getCommit().getChanges();
boolean commitNotCancelled = myPlatformFacade.getVcsHelper(myProject).commitChanges(changes, changeList, commitMessage,
new CommitResultHandler() {
@Override
public void onSuccess(@NotNull String commitMessage) {
commit.setActualSubject(commitMessage);
commitSucceeded.set(true);
sem.release();
}
@Override
public void onFailure() {
commitSucceeded.set(false);
sem.release();
}
});
if (!commitNotCancelled) {
commitSucceeded.set(false);
sem.release();
}
} catch (Throwable t) {
LOG.error(t);
commitSucceeded.set(false);
sem.release();
}
}
}, ModalityState.NON_MODAL);
// need additional waiting, because commitChanges is asynchronous
try {
sem.acquire();
}
catch (InterruptedException e) {
LOG.error(e);
return false;
}
return commitSucceeded.get();
}
/**
* We control the cherry-pick workflow ourselves + we want to use partial commits ('git commit --only'), which is prohibited during
* cherry-pick, i.e. until the CHERRY_PICK_HEAD exists.
*/
private void cancelCherryPick(@NotNull GitRepository repository) {
if (myAutoCommit) {
removeCherryPickHead(repository);
}
}
private void removeCherryPickHead(@NotNull GitRepository repository) {
File cherryPickHeadFile = new File(repository.getGitDir().getPath(), CHERRY_PICK_HEAD_FILE);
final VirtualFile cherryPickHead = myPlatformFacade.getLocalFileSystem().refreshAndFindFileByIoFile(cherryPickHeadFile);
if (cherryPickHead != null && cherryPickHead.exists()) {
myPlatformFacade.runWriteAction(new Runnable() {
@Override
public void run() {
try {
cherryPickHead.delete(this);
}
catch (IOException e) {
// if CHERRY_PICK_HEAD is not deleted, the partial commit will fail, and the user will be notified anyway.
// So here we just log the fact. It is happens relatively often, maybe some additional solution will follow.
LOG.error(e);
}
}
});
}
else {
LOG.info("Cancel cherry-pick in " + repository.getPresentableUrl() + ": no CHERRY_PICK_HEAD found");
}
}
private void notifyError(@NotNull String content,
@NotNull GitCommitWrapper failedCommit,
@NotNull List<GitCommitWrapper> successfulCommits) {
String description = commitDetails(failedCommit) + "<br/>" + content;
description += getSuccessfulCommitDetailsIfAny(successfulCommits);
VcsNotifier.getInstance(myProject).notifyError("Cherry-pick failed", description);
}
@NotNull
private static String getSuccessfulCommitDetailsIfAny(@NotNull List<GitCommitWrapper> successfulCommits) {
String description = "";
if (!successfulCommits.isEmpty()) {
description += "<hr/>However cherry-pick succeeded for the following " + pluralize("commit", successfulCommits.size()) + ":<br/>";
description += getCommitsDetails(successfulCommits);
}
return description;
}
private void notifySuccess(@NotNull List<GitCommitWrapper> successfulCommits) {
String description = getCommitsDetails(successfulCommits);
VcsNotifier.getInstance(myProject).notifySuccess("Cherry-pick successful", description);
}
@NotNull
private static String getCommitsDetails(@NotNull List<GitCommitWrapper> successfulCommits) {
String description = "";
for (GitCommitWrapper commit : successfulCommits) {
description += commitDetails(commit) + "<br/>";
}
return description.substring(0, description.length() - "<br/>".length());
}
@NotNull
private static String commitDetails(@NotNull GitCommitWrapper commit) {
return commit.getCommit().getId().toShortString() + " " + commit.getOriginalSubject();
}
private void refreshChangedFiles(@NotNull Collection<FilePath> filePaths) {
List<VirtualFile> virtualFiles = ContainerUtil.skipNulls(ContainerUtil.map(filePaths, new Function<FilePath, VirtualFile>() {
@Override
public VirtualFile fun(FilePath file) {
return myPlatformFacade.getLocalFileSystem().refreshAndFindFileByPath(file.getPath());
}
}));
VfsUtil.markDirtyAndRefresh(false, false, false, ArrayUtil.toObjectArray(virtualFiles, VirtualFile.class));
}
@NotNull
private LocalChangeList createChangeList(@NotNull VcsFullCommitDetails commit, @NotNull String commitMessage) {
Collection<Change> changes = commit.getChanges();
if (!changes.isEmpty()) {
String changeListName = createNameForChangeList(commitMessage, 0).replace('\n', ' ');
final LocalChangeList changeList = ((ChangeListManagerEx)myChangeListManager).addChangeList(changeListName, commitMessage, commit);
myChangeListManager.moveChangesTo(changeList, changes.toArray(new Change[changes.size()]));
myChangeListManager.setDefaultChangeList(changeList);
return changeList;
}
return myChangeListManager.getDefaultChangeList();
}
@NotNull
private String createNameForChangeList(@NotNull String proposedName, int step) {
for (LocalChangeList list : myChangeListManager.getChangeLists()) {
if (list.getName().equals(nameWithStep(proposedName, step))) {
return createNameForChangeList(proposedName, step + 1);
}
}
return nameWithStep(proposedName, step);
}
private static String nameWithStep(String name, int step) {
return step == 0 ? name : name + "-" + step;
}
private static class CherryPickData {
private final LocalChangeList myChangeList;
private final String myCommitMessage;
private final LocalChangeList myPreviouslyDefaultChangeList;
private CherryPickData(LocalChangeList list, String message, LocalChangeList previouslyDefaultChangeList) {
myChangeList = list;
myCommitMessage = message;
myPreviouslyDefaultChangeList = previouslyDefaultChangeList;
}
}
private static class CherryPickConflictResolver extends GitConflictResolver {
public CherryPickConflictResolver(@NotNull Project project, @NotNull Git git, @NotNull GitPlatformFacade facade, @NotNull VirtualFile root,
@NotNull String commitHash, @NotNull String commitAuthor, @NotNull String commitMessage) {
super(project, git, facade, Collections.singleton(root), makeParams(commitHash, commitAuthor, commitMessage));
}
private static Params makeParams(String commitHash, String commitAuthor, String commitMessage) {
Params params = new Params();
params.setErrorNotificationTitle("Cherry-picked with conflicts");
params.setMergeDialogCustomizer(new CherryPickMergeDialogCustomizer(commitHash, commitAuthor, commitMessage));
return params;
}
@Override
protected void notifyUnresolvedRemain() {
// we show a [possibly] compound notification after cherry-picking all commits.
}
}
private static class ResolveLinkListener implements NotificationListener {
@NotNull private final Project myProject;
@NotNull private final Git myGit;
@NotNull private final GitPlatformFacade myFacade;
@NotNull private final VirtualFile myRoot;
@NotNull private final String myHash;
@NotNull private final String myAuthor;
@NotNull private final String myMessage;
public ResolveLinkListener(@NotNull Project project, @NotNull Git git, @NotNull GitPlatformFacade facade, @NotNull VirtualFile root,
@NotNull String commitHash, @NotNull String commitAuthor, @NotNull String commitMessage) {
myProject = project;
myGit = git;
myFacade = facade;
myRoot = root;
myHash = commitHash;
myAuthor = commitAuthor;
myMessage = commitMessage;
}
@Override
public void hyperlinkUpdate(@NotNull Notification notification,
@NotNull HyperlinkEvent event) {
if (event.getEventType() == HyperlinkEvent.EventType.ACTIVATED) {
if (event.getDescription().equals("resolve")) {
new CherryPickConflictResolver(myProject, myGit, myFacade, myRoot, myHash, myAuthor, myMessage).mergeNoProceed();
}
}
}
}
private static class CherryPickMergeDialogCustomizer extends MergeDialogCustomizer {
private String myCommitHash;
private String myCommitAuthor;
private String myCommitMessage;
public CherryPickMergeDialogCustomizer(String commitHash, String commitAuthor, String commitMessage) {
myCommitHash = commitHash;
myCommitAuthor = commitAuthor;
myCommitMessage = commitMessage;
}
@Override
public String getMultipleFileMergeDescription(Collection<VirtualFile> files) {
return "<html>Conflicts during cherry-picking commit <code>" + myCommitHash + "</code> made by " + myCommitAuthor + "<br/>" +
"<code>\"" + myCommitMessage + "\"</code></html>";
}
@Override
public String getLeftPanelTitle(VirtualFile file) {
return "Local changes";
}
@Override
public String getRightPanelTitle(VirtualFile file, VcsRevisionNumber lastRevisionNumber) {
return "<html>Changes from cherry-pick <code>" + myCommitHash + "</code>";
}
}
/**
* This class is needed to hold both the original GitCommit, and the commit message which could be changed by the user.
* Only the subject of the commit message is needed.
*/
private static class GitCommitWrapper {
@NotNull private final VcsFullCommitDetails myOriginalCommit;
@NotNull private String myActualSubject;
private GitCommitWrapper(@NotNull VcsFullCommitDetails commit) {
myOriginalCommit = commit;
myActualSubject = commit.getSubject();
}
@NotNull
public String getSubject() {
return myActualSubject;
}
public void setActualSubject(@NotNull String actualSubject) {
myActualSubject = actualSubject;
}
@NotNull
public VcsFullCommitDetails getCommit() {
return myOriginalCommit;
}
public String getOriginalSubject() {
return myOriginalCommit.getSubject();
}
}
}