diff --git a/plugins/git4idea/src/git4idea/GitUtil.java b/plugins/git4idea/src/git4idea/GitUtil.java index 6fa5f09f2b45..c49f36644ff4 100644 --- a/plugins/git4idea/src/git4idea/GitUtil.java +++ b/plugins/git4idea/src/git4idea/GitUtil.java @@ -18,10 +18,13 @@ package git4idea; import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.project.Project; import com.intellij.openapi.util.Pair; +import com.intellij.openapi.util.io.FileUtil; import com.intellij.openapi.util.text.StringUtil; import com.intellij.openapi.vcs.FilePath; import com.intellij.openapi.vcs.ProjectLevelVcsManager; import com.intellij.openapi.vcs.VcsException; +import com.intellij.openapi.vcs.changes.Change; +import com.intellij.openapi.vcs.changes.ChangeListManager; import com.intellij.openapi.vcs.changes.FilePathsHelper; import com.intellij.openapi.vcs.vfs.AbstractVcsVirtualFile; import com.intellij.openapi.vfs.LocalFileSystem; @@ -673,4 +676,34 @@ public class GitUtil { } return remoteChanges; } + + /** + * Given the list of paths converts them to the list of {@link Change Changes} found in the {@link ChangeListManager}, + * i.e. this works only for local changes. + * Paths can be absolute or relative to the repository. + * If a path is not in the local changes, it is ignored. + */ + @NotNull + public static List convertPathsToChanges(@NotNull GitRepository repository, + @NotNull Collection affectedPaths, boolean relativePaths) { + ChangeListManager changeListManager = ChangeListManager.getInstance(repository.getProject()); + List affectedChanges = new ArrayList(); + for (String path : affectedPaths) { + VirtualFile file; + if (relativePaths) { + file = repository.getRoot().findFileByRelativePath(FileUtil.toSystemIndependentName(path)); + } + else { + file = VcsUtil.getVirtualFile(path); + } + + if (file != null) { + Change change = changeListManager.getChange(file); + if (change != null) { + affectedChanges.add(change); + } + } + } + return affectedChanges; + } } diff --git a/plugins/git4idea/src/git4idea/GitVcs.java b/plugins/git4idea/src/git4idea/GitVcs.java index 960ae602b6b9..e4f48069e5e3 100644 --- a/plugins/git4idea/src/git4idea/GitVcs.java +++ b/plugins/git4idea/src/git4idea/GitVcs.java @@ -91,7 +91,7 @@ import java.util.concurrent.locks.ReentrantReadWriteLock; */ public class GitVcs extends AbstractVcs { public static final NotificationGroup NOTIFICATION_GROUP_ID = NotificationGroup.toolWindowGroup("Git Messages", ChangesViewContentManager.TOOLWINDOW_ID, true); - public static final NotificationGroup IMPORTANT_ERROR_NOTIFICATION = new NotificationGroup("Git Important Errors", NotificationDisplayType.STICKY_BALLOON, true); + public static final NotificationGroup IMPORTANT_ERROR_NOTIFICATION = new NotificationGroup("Git Important Messages", NotificationDisplayType.STICKY_BALLOON, true); static { NotificationsConfigurationImpl.remove("Git"); diff --git a/plugins/git4idea/src/git4idea/NotificationManager.java b/plugins/git4idea/src/git4idea/NotificationManager.java index 93ad28e5b8cd..7977bca38c30 100644 --- a/plugins/git4idea/src/git4idea/NotificationManager.java +++ b/plugins/git4idea/src/git4idea/NotificationManager.java @@ -48,6 +48,10 @@ public class NotificationManager { notify(notificationGroup, title, message, type, null); } + public void notifyError(String title, String message) { + notify(GitVcs.IMPORTANT_ERROR_NOTIFICATION, title, message, NotificationType.ERROR, null); + } + protected static Notification createNotification(NotificationGroup notificationGroup, String title, String message, NotificationType type, NotificationListener listener) { return notificationGroup.createNotification(title, message, type, listener); diff --git a/plugins/git4idea/src/git4idea/branch/GitBranchOperation.java b/plugins/git4idea/src/git4idea/branch/GitBranchOperation.java index 2c2d519fe3f1..d9676498e5e1 100644 --- a/plugins/git4idea/src/git4idea/branch/GitBranchOperation.java +++ b/plugins/git4idea/src/git4idea/branch/GitBranchOperation.java @@ -18,26 +18,34 @@ package git4idea.branch; import com.intellij.notification.Notification; import com.intellij.notification.NotificationListener; import com.intellij.notification.NotificationType; +import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.progress.ProgressIndicator; import com.intellij.openapi.project.Project; import com.intellij.openapi.ui.Messages; +import com.intellij.openapi.ui.VerticalFlowLayout; +import com.intellij.openapi.util.Pair; import com.intellij.openapi.util.text.StringUtil; +import com.intellij.openapi.vcs.VcsException; +import com.intellij.openapi.vcs.changes.Change; +import com.intellij.openapi.vcs.changes.ui.SelectFilesDialog; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.ui.components.JBLabel; import com.intellij.util.Function; import com.intellij.util.ui.UIUtil; -import git4idea.GitUtil; -import git4idea.GitVcs; -import git4idea.MessageManager; -import git4idea.NotificationManager; +import git4idea.*; +import git4idea.commands.GitMessageWithFilesDetector; import git4idea.merge.GitConflictResolver; import git4idea.repo.GitRepository; +import git4idea.util.UntrackedFilesNotifier; import org.jetbrains.annotations.NotNull; +import javax.swing.*; import javax.swing.event.HyperlinkEvent; -import java.util.ArrayList; -import java.util.Collection; +import java.util.*; import java.util.concurrent.atomic.AtomicBoolean; import static com.intellij.openapi.util.text.StringUtil.pluralize; +import static com.intellij.openapi.util.text.StringUtil.stripHtml; /** * Common class for Git operations with branches aware of multi-root configuration, @@ -47,10 +55,7 @@ import static com.intellij.openapi.util.text.StringUtil.pluralize; */ abstract class GitBranchOperation { - static final String UNMERGED_FILES_ERROR_TITLE = "Can't checkout because of unmerged files"; - static final String UNMERGED_FILES_ERROR_NOTIFICATION_DESCRIPTION = - "You have to resolve all merge conflicts before checkout.
" + - "After resolving conflicts you also probably would want to commit your files to the current branch."; + private static final Logger LOG = Logger.getInstance(GitBranchOperation.class); @NotNull protected final Project myProject; @NotNull private final Collection myRepositories; @@ -78,6 +83,14 @@ abstract class GitBranchOperation { @NotNull protected abstract String getRollbackProposal(); + /** + * Returns a short downcased name of the operation. + * It is used by some dialogs or notifications which are common to several operations. + * Some operations (like checkout new branch) can be not mentioned in these dialogs, so their operation names would be not used. + */ + @NotNull + protected abstract String getOperationName(); + /** * @return next repository that wasn't handled (e.g. checked out) yet. */ @@ -135,8 +148,19 @@ abstract class GitBranchOperation { return myRemainingRepositories; } + @NotNull + protected List getRemainingRepositoriesExceptGiven(@NotNull final GitRepository currentRepository) { + List repositories = new ArrayList(myRemainingRepositories); + repositories.remove(currentRepository); + return repositories; + } + + protected void notifySuccess(@NotNull String message) { + NotificationManager.getInstance(myProject).notify(GitVcs.NOTIFICATION_GROUP_ID, "", message, NotificationType.INFORMATION); + } + protected void notifySuccess() { - NotificationManager.getInstance(myProject).notify(GitVcs.NOTIFICATION_GROUP_ID, "", getSuccessMessage(), NotificationType.INFORMATION); + notifySuccess(getSuccessMessage()); } /** @@ -166,6 +190,27 @@ abstract class GitBranchOperation { } } + @NotNull + private String unmergedFilesErrorTitle() { + return unmergedFilesErrorTitle(getOperationName()); + } + + @NotNull + private static String unmergedFilesErrorTitle(String operationName) { + return "Can't " + operationName + " because of unmerged files"; + } + + @NotNull + private String unmergedFilesErrorNotificationDescription() { + return unmergedFilesErrorNotificationDescription(getOperationName()); + } + + @NotNull + private static String unmergedFilesErrorNotificationDescription(String operationName) { + return "You have to resolve all merge conflicts before " + operationName + ".
" + + "After resolving conflicts you also probably would want to commit your files to the current branch."; + } + protected void showFatalNotification(@NotNull String title, @NotNull String message) { notifyError(title, message); } @@ -201,10 +246,12 @@ abstract class GitBranchOperation { final AtomicBoolean ok = new AtomicBoolean(); UIUtil.invokeAndWaitIfNeeded(new Runnable() { @Override public void run() { - String description = "You have to resolve all merge conflicts before checkout.
" + getRollbackProposal() + ""; + String description = "You have to resolve all merge conflicts before " + getOperationName() + ".
" + + getRollbackProposal() + ""; // suppressing: this message looks ugly if capitalized by words //noinspection DialogTitleCapitalization - ok.set(Messages.OK == MessageManager.showYesNoDialog(myProject, description, UNMERGED_FILES_ERROR_TITLE, "Rollback", "Don't rollback", Messages.getErrorIcon())); + ok.set(Messages.OK == MessageManager.showYesNoDialog(myProject, description, unmergedFilesErrorTitle(), + "Rollback", "Don't rollback", Messages.getErrorIcon())); } }); if (ok.get()) { @@ -213,13 +260,15 @@ abstract class GitBranchOperation { } private void showUnmergedFilesNotification() { - String title = UNMERGED_FILES_ERROR_TITLE; - String description = UNMERGED_FILES_ERROR_NOTIFICATION_DESCRIPTION; - NotificationManager.getInstance(myProject).notify(GitVcs.IMPORTANT_ERROR_NOTIFICATION, title, description, NotificationType.ERROR, new NotificationListener() { + String title = unmergedFilesErrorTitle(); + String description = unmergedFilesErrorNotificationDescription(); + NotificationManager.getInstance(myProject).notify(GitVcs.IMPORTANT_ERROR_NOTIFICATION, title, description, NotificationType.ERROR, + new NotificationListener() { @Override public void hyperlinkUpdate(@NotNull Notification notification, @NotNull HyperlinkEvent event) { if (event.getEventType() == HyperlinkEvent.EventType.ACTIVATED && event.getDescription().equals("resolve")) { GitConflictResolver.Params params = new GitConflictResolver.Params(). - setMergeDescription("The following files have unresolved conflicts. You need to resolve them before checking out."). + setMergeDescription("The following files have unresolved conflicts. You need to resolve them before " + + getOperationName() + "."). setErrorNotificationTitle("Unresolved files remain."); new GitConflictResolver(myProject, GitUtil.getRoots(getRepositories()), params).merge(); } @@ -227,4 +276,139 @@ abstract class GitBranchOperation { }); } + /** + * Asynchronously refreshes the VFS root directory of the given repository. + */ + protected static void refreshRoot(@NotNull GitRepository repository) { + repository.getRoot().refresh(true, true); + } + + protected void fatalLocalChangesError(@NotNull String reference) { + String title = String.format("Couldn't %s %s", getOperationName(), reference); + String message = String.format("Local changes would be overwritten by %s.
You should stash or commit them.
", getOperationName()); + if (wereSuccessful()) { + showFatalErrorDialogWithRollback(title, message); + } + else { + showFatalNotification(title, message); + } + } + + /** + * Shows the error "The following untracked working tree files would be overwritten by checkout/merge". + * If there were no repositories that succeeded the operation, shows a notification with a link to the list of these untracked files. + * If some repositories succeeded, shows a dialog with the list of these files and a proposal to rollback the operation of those + * repositories. + */ + protected void fatalUntrackedFilesError(@NotNull Collection untrackedFiles) { + if (wereSuccessful()) { + showUntrackedFilesDialogWithRollback(untrackedFiles); + } + else { + showUntrackedFilesNotification(untrackedFiles); + } + } + + private void showUntrackedFilesNotification(@NotNull Collection untrackedFiles) { + UntrackedFilesNotifier.notifyUntrackedFilesOverwrittenBy(myProject, untrackedFiles, getOperationName()); + } + + private void showUntrackedFilesDialogWithRollback(@NotNull Collection untrackedFiles) { + String title = "Couldn't " + getOperationName(); + String description = UntrackedFilesNotifier.createUntrackedFilesOverwrittenDescription(getOperationName(), true); + + final SelectFilesDialog dialog = new UntrackedFilesDialog(myProject, new ArrayList(untrackedFiles), + stripHtml(description, true)); + dialog.setTitle(title); + UIUtil.invokeAndWaitIfNeeded(new Runnable() { + @Override + public void run() { + DialogManager.getInstance(myProject).showDialog(dialog); + } + }); + + if (dialog.isOK()) { + rollback(); + } + } + + /** + * TODO this is non-optimal and even incorrect, since such diff shows the difference between committed changes + * For each of the given repositories looks to the diff between current branch and the given branch and converts it to the list of + * local changes. + */ + @NotNull + static Map> collectLocalChangesConflictingWithBranch(@NotNull Project project, + @NotNull Collection repositories, + @NotNull String currentBranch, + @NotNull String otherBranch) { + Map> changes = new HashMap>(); + for (GitRepository repository : repositories) { + try { + Collection diff = GitUtil.getPathsDiffBetweenRefs(currentBranch, otherBranch, project, repository.getRoot()); + List changesInRepo = GitUtil.convertPathsToChanges(repository, diff, false); + if (!changesInRepo.isEmpty()) { + changes.put(repository, changesInRepo); + } + } + catch (VcsException e) { + // ignoring the exception: this is not fatal if we won't collect such a diff from other repositories. + // At worst, use will get double dialog proposing the smart checkout. + LOG.warn(String.format("Couldn't collect diff between %s and %s in %s", currentBranch, otherBranch, repository.getRoot()), e); + } + } + return changes; + } + + private class UntrackedFilesDialog extends SelectFilesDialog { + + public UntrackedFilesDialog(@NotNull Project project, @NotNull List originalFiles, @NotNull String prompt) { + super(project, originalFiles, prompt, null, false, false); + setOKButtonText("Rollback"); + setCancelButtonText("Don't rollback"); + } + + @Override + protected JComponent createSouthPanel() { + JComponent buttons = super.createSouthPanel(); + JPanel panel = new JPanel(new VerticalFlowLayout()); + panel.add(new JBLabel("" + getRollbackProposal() + "")); + panel.add(buttons); + return panel; + } + } + + /** + * When checkout or merge operation on a repository fails with the error "local changes would be overwritten by...", + * affected local files are captured by the {@link git4idea.commands.GitMessageWithFilesDetector detector}. + * Then all remaining (non successful repositories) are searched if they are about to fail with the same problem. + * All collected local changes which prevent the operation, together with these repositories, are returned. + * @param currentRepository The first repository which failed the operation. + * @param localChangesOverwrittenBy The detector of local changes would be overwritten by merge/checkout. + * @param currentBranch Current branch. + * @param nextBranch Branch to compare with (the branch to be checked out, or the branch to be merged). + * @return Repositories that have failed or would fail with the "local changes" error, together with these local changes. + */ + @NotNull + protected Pair, List> getConflictingRepositoriesAndAffectedChanges( + @NotNull GitRepository currentRepository, @NotNull GitMessageWithFilesDetector localChangesOverwrittenBy, + String currentBranch, String nextBranch) { + + // get changes overwritten by checkout from the error message captured from Git + List affectedChanges = GitUtil.convertPathsToChanges(currentRepository, localChangesOverwrittenBy.getRelativeFilePaths(), true); + // get all other conflicting changes + // get changes in all other repositories (except those which already have succeeded) to avoid multiple dialogs proposing smart checkout + Map> conflictingChangesInRepositories = + collectLocalChangesConflictingWithBranch(myProject, getRemainingRepositoriesExceptGiven(currentRepository), currentBranch, nextBranch); + + Set otherProblematicRepositories = conflictingChangesInRepositories.keySet(); + List allConflictingRepositories = new ArrayList(otherProblematicRepositories); + allConflictingRepositories.add(currentRepository); + for (List changes : conflictingChangesInRepositories.values()) { + affectedChanges.addAll(changes); + } + + return Pair.create(allConflictingRepositories, affectedChanges); + } + } diff --git a/plugins/git4idea/src/git4idea/branch/GitBranchOperationsProcessor.java b/plugins/git4idea/src/git4idea/branch/GitBranchOperationsProcessor.java index df1989fdc3de..e066aa825424 100644 --- a/plugins/git4idea/src/git4idea/branch/GitBranchOperationsProcessor.java +++ b/plugins/git4idea/src/git4idea/branch/GitBranchOperationsProcessor.java @@ -39,7 +39,9 @@ import org.jetbrains.annotations.Nullable; import javax.swing.*; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** * Executor of Git branching operations. @@ -172,11 +174,7 @@ public final class GitBranchOperationsProcessor { } private void doDelete(final String branchName, ProgressIndicator indicator) { - GitDeleteBranchOperation operation = new GitDeleteBranchOperation(myProject, myRepositories, branchName, getCurrentBranch(), indicator); - operation.execute(); - for (GitRepository repository : myRepositories) { - repository.update(GitRepository.TrackedTopic.BRANCHES, GitRepository.TrackedTopic.CONFIG); - } + new GitDeleteBranchOperation(myProject, myRepositories, branchName, getCurrentBranch(), indicator).execute(); } /** @@ -236,6 +234,22 @@ public final class GitBranchOperationsProcessor { } } + public void merge(@NotNull final String branchName) { + new CommonBackgroundTask(myProject, "Merging " + branchName, myCallInAwtAfterExecution) { + @Override public void execute(@NotNull ProgressIndicator indicator) { + doMerge(branchName, indicator); + } + }.runInBackground(); + } + + private void doMerge(@NotNull String branchName, @NotNull ProgressIndicator indicator) { + Map revisions = new HashMap(); + for (GitRepository repository : myRepositories) { + revisions.put(repository, repository.getCurrentRevision()); + } + new GitMergeOperation(myProject, myRepositories, branchName, getCurrentBranch(), revisions, indicator).execute(); + } + /** * Executes common operations before/after executing the actual branch operation. */ diff --git a/plugins/git4idea/src/git4idea/branch/GitCheckoutNewBranchOperation.java b/plugins/git4idea/src/git4idea/branch/GitCheckoutNewBranchOperation.java index ec9f340839e0..27f135bac9a3 100644 --- a/plugins/git4idea/src/git4idea/branch/GitCheckoutNewBranchOperation.java +++ b/plugins/git4idea/src/git4idea/branch/GitCheckoutNewBranchOperation.java @@ -37,13 +37,13 @@ import static git4idea.util.GitUIUtil.code; * * @author Kirill Likhodedov */ -public class GitCheckoutNewBranchOperation extends GitBranchOperation { +class GitCheckoutNewBranchOperation extends GitBranchOperation { @NotNull private final Project myProject; @NotNull private final String myNewBranchName; @NotNull private final String myPreviousBranch; - public GitCheckoutNewBranchOperation(@NotNull Project project, @NotNull Collection repositories, + GitCheckoutNewBranchOperation(@NotNull Project project, @NotNull Collection repositories, @NotNull String newBranchName, @NotNull String previousBranch, @NotNull ProgressIndicator indicator) { super(project, repositories, indicator); @@ -58,7 +58,7 @@ public class GitCheckoutNewBranchOperation extends GitBranchOperation { while (hasMoreRepositories() && !fatalErrorHappened) { final GitRepository repository = next(); - GitSimpleEventDetector unmergedDetector = new GitSimpleEventDetector(GitSimpleEventDetector.Event.UNMERGED); + GitSimpleEventDetector unmergedDetector = new GitSimpleEventDetector(GitSimpleEventDetector.Event.UNMERGED_PREVENTING_CHECKOUT); GitCommandResult result = Git.checkoutNewBranch(repository, myNewBranchName, unmergedDetector); if (result.success()) { @@ -98,6 +98,12 @@ public class GitCheckoutNewBranchOperation extends GitBranchOperation { "
You may rollback (checkout back to " + myPreviousBranch + " and delete " + myNewBranchName + ") not to let branches diverge."; } + @NotNull + @Override + protected String getOperationName() { + return "checkout"; + } + @Override protected void rollback() { GitCompoundResult checkoutResult = new GitCompoundResult(myProject); diff --git a/plugins/git4idea/src/git4idea/branch/GitCheckoutOperation.java b/plugins/git4idea/src/git4idea/branch/GitCheckoutOperation.java index 06f93d1c9233..6585ce74fd73 100644 --- a/plugins/git4idea/src/git4idea/branch/GitCheckoutOperation.java +++ b/plugins/git4idea/src/git4idea/branch/GitCheckoutOperation.java @@ -19,42 +19,21 @@ import com.intellij.notification.NotificationType; import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.progress.ProgressIndicator; import com.intellij.openapi.project.Project; -import com.intellij.openapi.ui.VerticalFlowLayout; -import com.intellij.openapi.util.Clock; -import com.intellij.openapi.util.Condition; -import com.intellij.openapi.util.io.FileUtil; -import com.intellij.openapi.vcs.VcsException; +import com.intellij.openapi.util.Pair; import com.intellij.openapi.vcs.changes.Change; -import com.intellij.openapi.vcs.changes.ChangeListManager; -import com.intellij.openapi.vcs.changes.ui.SelectFilesDialog; -import com.intellij.openapi.vcs.history.VcsRevisionNumber; -import com.intellij.openapi.vcs.merge.MergeDialogCustomizer; import com.intellij.openapi.vfs.VirtualFile; -import com.intellij.ui.components.JBLabel; -import com.intellij.util.ArrayUtil; -import com.intellij.util.containers.ContainerUtil; -import com.intellij.util.continuation.ContinuationContext; -import com.intellij.util.text.DateFormatUtil; -import com.intellij.util.ui.UIUtil; -import com.intellij.vcsUtil.VcsUtil; -import git4idea.DialogManager; -import git4idea.GitUtil; import git4idea.GitVcs; import git4idea.commands.*; -import git4idea.merge.GitConflictResolver; import git4idea.repo.GitRepository; -import git4idea.stash.GitChangesSaver; -import git4idea.update.GitComplexProcess; +import git4idea.util.GitPreservingProcess; import git4idea.util.GitUIUtil; -import git4idea.util.UntrackedFilesNotifier; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import javax.swing.*; -import java.util.*; +import java.util.Collection; +import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; -import static com.intellij.openapi.util.text.StringUtil.*; import static git4idea.commands.GitMessageWithFilesDetector.Event.LOCAL_CHANGES_OVERWRITTEN_BY_CHECKOUT; import static git4idea.commands.GitMessageWithFilesDetector.Event.UNTRACKED_FILES_OVERWRITTEN_BY; import static git4idea.util.GitUIUtil.code; @@ -68,15 +47,16 @@ import static git4idea.util.GitUIUtil.code; * * @author Kirill Likhodedov */ -public class GitCheckoutOperation extends GitBranchOperation { +class GitCheckoutOperation extends GitBranchOperation { private static final Logger LOG = Logger.getInstance(GitCheckoutOperation.class); + public static final String ROLLBACK_PROPOSAL_FORMAT = "You may rollback (checkout back to %s) not to let branches diverge."; @NotNull private final String myStartPointReference; @Nullable private final String myNewBranch; @NotNull private final String myPreviousBranch; - public GitCheckoutOperation(@NotNull Project project, @NotNull Collection repositories, + GitCheckoutOperation(@NotNull Project project, @NotNull Collection repositories, @NotNull String startPointReference, @Nullable String newBranch, @NotNull String previousBranch, @NotNull ProgressIndicator indicator) { super(project, repositories, indicator); @@ -93,7 +73,7 @@ public class GitCheckoutOperation extends GitBranchOperation { VirtualFile root = repository.getRoot(); GitMessageWithFilesDetector localChangesOverwrittenByCheckout = new GitMessageWithFilesDetector(LOCAL_CHANGES_OVERWRITTEN_BY_CHECKOUT, root); - GitSimpleEventDetector unmergedFiles = new GitSimpleEventDetector(GitSimpleEventDetector.Event.UNMERGED); + GitSimpleEventDetector unmergedFiles = new GitSimpleEventDetector(GitSimpleEventDetector.Event.UNMERGED_PREVENTING_CHECKOUT); GitMessageWithFilesDetector untrackedOverwrittenByCheckout = new GitMessageWithFilesDetector(UNTRACKED_FILES_OVERWRITTEN_BY, root); GitCommandResult result = Git.checkout(repository, myStartPointReference, myNewBranch, false, @@ -129,27 +109,19 @@ public class GitCheckoutOperation extends GitBranchOperation { private boolean smartCheckoutOrNotify(@NotNull GitRepository repository, @NotNull GitMessageWithFilesDetector localChangesOverwrittenByCheckout) { - // get changes overwritten by checkout from the error message captured from Git - List affectedChanges = getChangesAffectedByCheckout(repository, localChangesOverwrittenByCheckout.getRelativeFilePaths(), true); - // get all other conflicting changes - Map> conflictingChangesInRepositories = collectLocalChangesOnAllOtherRepositories(repository); - Set otherProblematicRepositories = conflictingChangesInRepositories.keySet(); - List allConflictingRepositories = new ArrayList(otherProblematicRepositories); - allConflictingRepositories.add(repository); - for (List changes : conflictingChangesInRepositories.values()) { - affectedChanges.addAll(changes); - } + Pair, List> conflictingRepositoriesAndAffectedChanges = + getConflictingRepositoriesAndAffectedChanges(repository, localChangesOverwrittenByCheckout, myPreviousBranch, myStartPointReference); + List allConflictingRepositories = conflictingRepositoriesAndAffectedChanges.getFirst(); + List affectedChanges = conflictingRepositoriesAndAffectedChanges.getSecond(); - int smartCheckoutDecision = GitWouldBeOverwrittenByCheckoutDialog.showAndGetAnswer(myProject, affectedChanges); - if (smartCheckoutDecision == GitWouldBeOverwrittenByCheckoutDialog.SMART_CHECKOUT) { + int smartCheckoutDecision = GitSmartOperationDialog.showAndGetAnswer(myProject, affectedChanges, "checkout", true); + if (smartCheckoutDecision == GitSmartOperationDialog.SMART_EXIT_CODE) { boolean smartCheckedOutSuccessfully = smartCheckout(allConflictingRepositories, myStartPointReference, myNewBranch, getIndicator()); if (smartCheckedOutSuccessfully) { - GitRepository[] otherRepositories = ArrayUtil.toObjectArray(otherProblematicRepositories, GitRepository.class); - - markSuccessful(repository); - markSuccessful(otherRepositories); - refresh(repository); - refresh(otherRepositories); + for (GitRepository conflictingRepository : allConflictingRepositories) { + markSuccessful(conflictingRepository); + refresh(conflictingRepository); + } return true; } else { @@ -157,94 +129,27 @@ public class GitCheckoutOperation extends GitBranchOperation { return false; } } - else if (smartCheckoutDecision == GitWouldBeOverwrittenByCheckoutDialog.FORCE_CHECKOUT_EXIT_CODE) { + else if (smartCheckoutDecision == GitSmartOperationDialog.FORCE_EXIT_CODE) { return checkoutOrNotify(allConflictingRepositories, myStartPointReference, myNewBranch, true); } else { - fatalLocalChangesError(); + fatalLocalChangesError(myStartPointReference); return false; } } - private void fatalLocalChangesError() { - String title = "Couldn't checkout " + myStartPointReference; - String message = "Local changes would be overwritten by checkout.
Stash or commit them before checking out a branch.
"; - if (wereSuccessful()) { - showFatalErrorDialogWithRollback(title, message); - } - else { - showFatalNotification(title, message); - } - } - - @NotNull - private Map> collectLocalChangesOnAllOtherRepositories(@NotNull final GitRepository currentRepository) { - // get changes in all other repositories (except those which already have succeeded) to avoid multiple dialogs proposing smart checkout - List remainingRepositories = ContainerUtil.filter(getRepositories(), new Condition() { - @Override - public boolean value(GitRepository repo) { - return !repo.equals(currentRepository) && !getSuccessfulRepositories().contains(repo); - } - }); - return collectChangesConflictingWithCheckout(remainingRepositories); - } - - private void fatalUntrackedFilesError(@NotNull Collection untrackedFiles) { - if (wereSuccessful()) { - showUntrackedFilesDialogWithRollback(untrackedFiles); - } - else { - showUntrackedFilesNotification(untrackedFiles); - } - } - - private void showUntrackedFilesNotification(@NotNull Collection untrackedFiles) { - UntrackedFilesNotifier.notifyUntrackedFilesOverwrittenBy(myProject, untrackedFiles, "checkout"); - } - - private void showUntrackedFilesDialogWithRollback(@NotNull Collection untrackedFiles) { - String title = "Couldn't checkout"; - String description = UntrackedFilesNotifier.createUntrackedFilesOverwrittenDescription("checkout", true); - - final SelectFilesDialog dialog = new UntrackedFilesDialog(myProject, new ArrayList(untrackedFiles), - stripHtml(description, true)); - dialog.setTitle(title); - UIUtil.invokeAndWaitIfNeeded(new Runnable() { - @Override - public void run() { - DialogManager.getInstance(myProject).showDialog(dialog); - } - }); - - if (dialog.isOK()) { - rollback(); - } - } - - private class UntrackedFilesDialog extends SelectFilesDialog { - - public UntrackedFilesDialog(@NotNull Project project, @NotNull List originalFiles, @NotNull String prompt) { - super(project, originalFiles, prompt, null, false, false); - setOKButtonText("Rollback"); - setCancelButtonText("Don't rollback"); - } - - @Override - protected JComponent createSouthPanel() { - JComponent buttons = super.createSouthPanel(); - JPanel panel = new JPanel(new VerticalFlowLayout()); - panel.add(new JBLabel("" + getRollbackProposal() + "")); - panel.add(buttons); - return panel; - } - } - @NotNull @Override protected String getRollbackProposal() { return "However checkout has succeeded for the following " + repositories() + ":
" + successfulRepositoriesJoined() + - "
You may rollback (checkout back to " + myPreviousBranch + ") not to let branches diverge."; + "
" + String.format(ROLLBACK_PROPOSAL_FORMAT, myPreviousBranch); + } + + @NotNull + @Override + protected String getOperationName() { + return "checkout"; } @Override @@ -284,26 +189,6 @@ public class GitCheckoutOperation extends GitBranchOperation { return "Couldn't checkout " + myStartPointReference; } - @NotNull - private Map> collectChangesConflictingWithCheckout(@NotNull Collection repositories) { - Map> changes = new HashMap>(); - for (GitRepository repository : repositories) { - try { - Collection diff = GitUtil.getPathsDiffBetweenRefs(myPreviousBranch, myStartPointReference, myProject, repository.getRoot()); - List changesInRepo = getChangesAffectedByCheckout(repository, diff, false); - if (!changesInRepo.isEmpty()) { - changes.put(repository, changesInRepo); - } - } - catch (VcsException e) { - // ignoring the exception: this is not fatal if we won't collect such a diff from other repositories. - // At worst, use will get double dialog proposing the smart checkout. - LOG.warn(String.format("Couldn't collect diff between %s and %s in %s", myPreviousBranch, myStartPointReference, repository.getRoot()), e); - } - } - return changes; - } - @NotNull @Override public String getSuccessMessage() { @@ -315,76 +200,18 @@ public class GitCheckoutOperation extends GitBranchOperation { // stash - checkout - unstash private boolean smartCheckout(@NotNull final List repositories, @NotNull final String reference, @Nullable final String newBranch, @NotNull ProgressIndicator indicator) { - final GitChangesSaver saver = configureSaver(reference, indicator); final AtomicBoolean result = new AtomicBoolean(); - GitComplexProcess.Operation checkoutOperation = new GitComplexProcess.Operation() { - @Override public void run(ContinuationContext context) { - boolean savedSuccessfully = save(repositories, saver); - if (savedSuccessfully) { - try { - result.set(checkoutOrNotify(repositories, reference, newBranch, false)); - } finally { - saver.restoreLocalChanges(context); - } - } + GitPreservingProcess preservingProcess = new GitPreservingProcess(myProject, repositories, "checkout", reference, indicator, new Runnable() { + @Override + public void run() { + result.set(checkoutOrNotify(repositories, reference, newBranch, false)); } - }; - GitComplexProcess.execute(myProject, "checkout", checkoutOperation); + }); + preservingProcess.execute(); return result.get(); } - /** - * Configures the saver, actually notifications and texts in the GitConflictResolver used inside. - */ - private GitChangesSaver configureSaver(final String reference, ProgressIndicator indicator) { - GitChangesSaver saver = GitChangesSaver.getSaver(myProject, indicator, String.format("Checkout %s at %s", - reference, - DateFormatUtil.formatDateTime(Clock.getTime()))); - MergeDialogCustomizer mergeDialogCustomizer = new MergeDialogCustomizer() { - @Override - public String getMultipleFileMergeDescription(Collection files) { - return String.format( - "Uncommitted changes that were saved before checkout have conflicts with files from %s", - reference); - } - - @Override - public String getLeftPanelTitle(VirtualFile file) { - return "Uncommitted changes"; - } - - @Override - public String getRightPanelTitle(VirtualFile file, VcsRevisionNumber lastRevisionNumber) { - return String.format("Changes from %s", reference); - } - }; - - GitConflictResolver.Params params = new GitConflictResolver.Params(). - setReverse(true). - setMergeDialogCustomizer(mergeDialogCustomizer). - setErrorNotificationTitle("Local changes were not restored"); - - saver.setConflictResolverParams(params); - return saver; - } - - /** - * Saves local changes. In case of error shows a notification and returns false. - */ - private boolean save(@NotNull Collection repositories, @NotNull GitChangesSaver saver) { - try { - saver.saveLocalChanges(GitUtil.getRoots(repositories)); - return true; - } catch (VcsException e) { - LOG.info("Couldn't save local changes", e); - notifyError("Couldn't save uncommitted changes.", - String.format("Tried to save uncommitted changes in %s before checkout, but failed with an error.
%s", - saver.getSaverName(), join(e.getMessages()))); - return false; - } - } - /** * Checks out or shows an error message. */ @@ -401,45 +228,10 @@ public class GitCheckoutOperation extends GitBranchOperation { return false; } - /** - * Forms the list of the changes, that would be overwritten by checkout. - * - * @param repository - * @param affectedPaths paths returned by Git. - * @param relativePaths Are the paths specified relative or absolute. - * @return List of Changes is these paths. - */ - private List getChangesAffectedByCheckout(@NotNull GitRepository repository, @NotNull Collection affectedPaths, boolean relativePaths) { - ChangeListManager changeListManager = ChangeListManager.getInstance(myProject); - List affectedChanges = new ArrayList(); - for (String path : affectedPaths) { - VirtualFile file; - if (relativePaths) { - file = repository.getRoot().findFileByRelativePath(FileUtil.toSystemIndependentName(path)); - } - else { - file = VcsUtil.getVirtualFile(path); - } - - if (file != null) { - Change change = changeListManager.getChange(file); - if (change != null) { - affectedChanges.add(change); - } - } - } - return affectedChanges; - } - private static void refresh(GitRepository... repositories) { for (GitRepository repository : repositories) { refreshRoot(repository); // repository state will be auto-updated with this VFS refresh => no need to call GitRepository#update(). } } - - private static void refreshRoot(GitRepository repository) { - repository.getRoot().refresh(true, true); - } - } diff --git a/plugins/git4idea/src/git4idea/branch/GitDeleteBranchOperation.java b/plugins/git4idea/src/git4idea/branch/GitDeleteBranchOperation.java index dc2b5ef1fcdc..0c4c89fa3f69 100644 --- a/plugins/git4idea/src/git4idea/branch/GitDeleteBranchOperation.java +++ b/plugins/git4idea/src/git4idea/branch/GitDeleteBranchOperation.java @@ -52,7 +52,7 @@ class GitDeleteBranchOperation extends GitBranchOperation { private final String myBranchName; private final String myCurrentBranch; - public GitDeleteBranchOperation(@NotNull Project project, @NotNull Collection repositories, + GitDeleteBranchOperation(@NotNull Project project, @NotNull Collection repositories, @NotNull String branchName, @NotNull String currentBranch, @NotNull ProgressIndicator indicator) { super(project, repositories, indicator); myBranchName = branchName; @@ -142,6 +142,12 @@ class GitDeleteBranchOperation extends GitBranchOperation { "
You may rollback (recreate " + myBranchName + " in these roots) not to let branches diverge."; } + @NotNull + @Override + protected String getOperationName() { + return "branch deletion"; + } + @NotNull private GitCompoundResult forceDelete(@NotNull String branchName, @NotNull Collection possibleFailedRepositories) { GitCompoundResult compoundResult = new GitCompoundResult(myProject); diff --git a/plugins/git4idea/src/git4idea/branch/GitMergeOperation.java b/plugins/git4idea/src/git4idea/branch/GitMergeOperation.java new file mode 100644 index 000000000000..fce48e4c0643 --- /dev/null +++ b/plugins/git4idea/src/git4idea/branch/GitMergeOperation.java @@ -0,0 +1,307 @@ +/* + * Copyright 2000-2012 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.branch; + +import com.intellij.notification.NotificationType; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.progress.ProgressIndicator; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.Computable; +import com.intellij.openapi.util.Pair; +import com.intellij.openapi.vcs.changes.Change; +import com.intellij.openapi.vcs.changes.ChangeListManager; +import com.intellij.openapi.vfs.VirtualFile; +import git4idea.GitUtil; +import git4idea.GitVcs; +import git4idea.NotificationManager; +import git4idea.commands.*; +import git4idea.merge.GitConflictResolver; +import git4idea.merge.GitMergeCommittingConflictResolver; +import git4idea.merge.GitMerger; +import git4idea.repo.GitRepository; +import git4idea.util.GitPreservingProcess; +import org.jetbrains.annotations.NotNull; + +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; + +import static git4idea.commands.GitMessageWithFilesDetector.Event.LOCAL_CHANGES_OVERWRITTEN_BY_MERGE; +import static git4idea.commands.GitMessageWithFilesDetector.Event.UNTRACKED_FILES_OVERWRITTEN_BY; + +/** + * @author Kirill Likhodedov + */ +class GitMergeOperation extends GitBranchOperation { + + private static final Logger LOG = Logger.getInstance(GitMergeOperation.class); + public static final String ROLLBACK_PROPOSAL = "You may rollback (reset to the commit before merging) not to let branches diverge."; + + @NotNull private final ChangeListManager myChangeListManager; + @NotNull private final String myBranchToMerge; + @NotNull private final String myCurrentBranch; + @NotNull private final Map myCurrentRevisionsBeforeMerge; + + // true in value if unstash will be needed after resolving conflicts. + @NotNull private final Map myConflictedRepositories = new HashMap(); + private GitPreservingProcess myPreservingProcess; + + protected GitMergeOperation(@NotNull Project project, @NotNull Collection repositories, + @NotNull String branchToMerge, + @NotNull String currentBranch, + @NotNull Map currentRevisionsBeforeMerge, + @NotNull ProgressIndicator indicator) { + super(project, repositories, indicator); + myBranchToMerge = branchToMerge; + myCurrentBranch = currentBranch; + myCurrentRevisionsBeforeMerge = currentRevisionsBeforeMerge; + myChangeListManager = ChangeListManager.getInstance(myProject); + } + + @Override + protected void execute() { + boolean fatalErrorHappened = false; + int alreadyUpToDateRepositories = 0; + while (hasMoreRepositories() && !fatalErrorHappened) { + final GitRepository repository = next(); + + VirtualFile root = repository.getRoot(); + GitMessageWithFilesDetector localChangesOverwrittenByMerge = new GitMessageWithFilesDetector(LOCAL_CHANGES_OVERWRITTEN_BY_MERGE, root); + GitSimpleEventDetector unmergedFiles = new GitSimpleEventDetector(GitSimpleEventDetector.Event.UNMERGED_PREVENTING_MERGE); + GitMessageWithFilesDetector untrackedOverwrittenByMerge = new GitMessageWithFilesDetector(UNTRACKED_FILES_OVERWRITTEN_BY, root); + GitSimpleEventDetector mergeConflict = new GitSimpleEventDetector(GitSimpleEventDetector.Event.MERGE_CONFLICT); + GitSimpleEventDetector alreadyUpToDateDetector = new GitSimpleEventDetector(GitSimpleEventDetector.Event.ALREADY_UP_TO_DATE); + + GitCommandResult result = Git.merge(repository, myBranchToMerge, + localChangesOverwrittenByMerge, unmergedFiles, untrackedOverwrittenByMerge, mergeConflict, + alreadyUpToDateDetector); + if (result.success()) { + refresh(repository); + markSuccessful(repository); + if (alreadyUpToDateDetector.hasHappened()) { + alreadyUpToDateRepositories += 1; + } + } + else if (unmergedFiles.hasHappened()) { + fatalUnmergedFilesError(); + fatalErrorHappened = true; + } + else if (localChangesOverwrittenByMerge.wasMessageDetected()) { + boolean smartMergeSucceeded = proposeSmartMergePerformAndNotify(repository, localChangesOverwrittenByMerge); + if (!smartMergeSucceeded) { + fatalErrorHappened = true; + } + } + else if (mergeConflict.hasHappened()) { + myConflictedRepositories.put(repository, Boolean.FALSE); + refresh(repository); + markSuccessful(repository); + } + else if (untrackedOverwrittenByMerge.wasMessageDetected()) { + fatalUntrackedFilesError(untrackedOverwrittenByMerge.getFiles()); + fatalErrorHappened = true; + } + else { + fatalError(getCommonErrorTitle(), result.getErrorOutputAsJoinedString()); + fatalErrorHappened = true; + } + } + + boolean allConflictsResolved = resolveConflicts(); + + if (!fatalErrorHappened && allConflictsResolved) { + if (alreadyUpToDateRepositories < getRepositories().size()) { + notifySuccess(); + } + else { + notifySuccess("Already up-to-date."); + } + } + + restoreLocalChanges(); + } + + private boolean resolveConflicts() { + if (!myConflictedRepositories.isEmpty()) { + GitMerger merger = new GitMerger(myProject); + GitConflictResolver.Params params = new GitConflictResolver.Params(); + GitMergeCommittingConflictResolver conflictResolver = + new GitMergeCommittingConflictResolver(myProject, merger, GitUtil.getRoots(myConflictedRepositories.keySet()), params, true) { + @Override + protected void notifyUnresolvedRemain() { + NotificationManager.getInstance(myProject).notify( + GitVcs.IMPORTANT_ERROR_NOTIFICATION, "Merged branch " + myBranchToMerge + " with conflicts", + "Unresolved conflicts remain in the project. Resolve now.", NotificationType.WARNING, + getResolveLinkListener()); + } + }; + return conflictResolver.merge(); + } + return true; + } + + private boolean proposeSmartMergePerformAndNotify(@NotNull GitRepository repository, + @NotNull GitMessageWithFilesDetector localChangesOverwrittenByMerge) { + Pair, List> conflictingRepositoriesAndAffectedChanges = + getConflictingRepositoriesAndAffectedChanges(repository, localChangesOverwrittenByMerge, myCurrentBranch, myBranchToMerge); + List allConflictingRepositories = conflictingRepositoriesAndAffectedChanges.getFirst(); + List affectedChanges = conflictingRepositoriesAndAffectedChanges.getSecond(); + + int smartCheckoutDecision = GitSmartOperationDialog.showAndGetAnswer(myProject, affectedChanges, "merge", false); + if (smartCheckoutDecision == GitSmartOperationDialog.SMART_EXIT_CODE) { + return doSmartMerge(allConflictingRepositories); + } + else { + fatalLocalChangesError(myBranchToMerge); + return false; + } + } + + private void restoreLocalChanges() { + if (myPreservingProcess != null) { + myPreservingProcess.load(); + } + } + + private boolean doSmartMerge(@NotNull final Collection repositories) { + final AtomicBoolean success = new AtomicBoolean(); + myPreservingProcess = new GitPreservingProcess(myProject, repositories, "merge", myBranchToMerge, getIndicator(), + new Runnable() { + @Override + public void run() { + success.set(doMerge(repositories)); + } + }); + myPreservingProcess.execute(new Computable() { + @Override + public Boolean compute() { + return myConflictedRepositories.isEmpty(); + } + }); + return success.get(); + } + + /** + * Performs merge in the given repositories. + * Handle only merge conflict situation: all other cases should have been handled before and are treated as errors. + * Conflict is treated as a success: the repository with conflict is remembered and will be handled later along with all other conflicts. + * If an error happens in one repository, the method doesn't go further in others, and shows a notification. + * + * @return true if merge has succeeded without errors (but possibly with conflicts) in all repositories; + * false if it failed at least in one of them. + */ + private boolean doMerge(@NotNull Collection repositories) { + for (GitRepository repository : repositories) { + GitSimpleEventDetector mergeConflict = new GitSimpleEventDetector(GitSimpleEventDetector.Event.MERGE_CONFLICT); + GitCommandResult result = Git.merge(repository, myBranchToMerge, mergeConflict); + if (!result.success()) { + if (mergeConflict.hasHappened()) { + myConflictedRepositories.put(repository, Boolean.TRUE); + refresh(repository); + markSuccessful(repository); + } + else { + fatalError(getCommonErrorTitle(), result.getErrorOutputAsJoinedString()); + return false; + } + } + else { + refresh(repository); + markSuccessful(repository); + } + } + return true; + } + + @NotNull + private String getCommonErrorTitle() { + return "Couldn't merge " + myBranchToMerge; + } + + @Override + protected void rollback() { + Collection repositoriesForSmartRollback = new ArrayList(); + Collection repositoriesForSimpleRollback = new ArrayList(); + for (GitRepository repository : getSuccessfulRepositories()) { + if (thereAreLocalChangesIn(repository)) { + repositoriesForSmartRollback.add(repository); + } + else { + repositoriesForSimpleRollback.add(repository); + } + } + + GitCompoundResult result = smartRollback(repositoriesForSmartRollback); + for (GitRepository repository : repositoriesForSimpleRollback) { + result.append(repository, rollback(repository)); + } + + if (!result.totalSuccess()) { + NotificationManager.getInstance(myProject).notifyError("Error during rollback", result.getErrorOutputWithReposIndication()); + } + } + + @NotNull + private GitCompoundResult smartRollback(@NotNull final Collection repositories) { + final GitCompoundResult result = new GitCompoundResult(myProject); + myPreservingProcess = new GitPreservingProcess(myProject, repositories, "merge", myBranchToMerge, getIndicator(), + new Runnable() { + @Override public void run() { + for (GitRepository repository : repositories) { + result.append(repository, rollback(repository)); + } + } + }); + myPreservingProcess.execute(); + return result; + } + + @NotNull + private GitCommandResult rollback(@NotNull GitRepository repository) { + return Git.hardReset(repository, myCurrentRevisionsBeforeMerge.get(repository)); + } + + private boolean thereAreLocalChangesIn(@NotNull GitRepository repository) { + return !myChangeListManager.getChangesIn(repository.getRoot()).isEmpty(); + } + + @NotNull + @Override + public String getSuccessMessage() { + return String.format("Merged %s to %s", myBranchToMerge, myCurrentBranch); + } + + @NotNull + @Override + protected String getRollbackProposal() { + return "However merge has succeeded for the following " + repositories() + ":
" + + successfulRepositoriesJoined() + + "
" + ROLLBACK_PROPOSAL; + } + + @NotNull + @Override + protected String getOperationName() { + return "merge"; + } + + private static void refresh(GitRepository... repositories) { + for (GitRepository repository : repositories) { + refreshRoot(repository); + repository.update(GitRepository.TrackedTopic.ALL_CURRENT); + } + } + +} diff --git a/plugins/git4idea/src/git4idea/branch/GitWouldBeOverwrittenByCheckoutDialog.java b/plugins/git4idea/src/git4idea/branch/GitSmartOperationDialog.java similarity index 64% rename from plugins/git4idea/src/git4idea/branch/GitWouldBeOverwrittenByCheckoutDialog.java rename to plugins/git4idea/src/git4idea/branch/GitSmartOperationDialog.java index ad60e17fcc44..0d8a187664e4 100644 --- a/plugins/git4idea/src/git4idea/branch/GitWouldBeOverwrittenByCheckoutDialog.java +++ b/plugins/git4idea/src/git4idea/branch/GitSmartOperationDialog.java @@ -17,6 +17,7 @@ package git4idea.branch; import com.intellij.openapi.project.Project; import com.intellij.openapi.ui.DialogWrapper; +import com.intellij.openapi.util.text.StringUtil; import com.intellij.openapi.vcs.changes.Change; import com.intellij.openapi.vcs.changes.ui.ChangesBrowser; import com.intellij.ui.IdeBorderFactory; @@ -31,29 +32,33 @@ import java.util.List; import java.util.concurrent.atomic.AtomicInteger; /** - * The dialog that is shown when the error "The following files would be overwritten by checkout" happens. - * Displays the list of these files and proposes to make a "smart" checkout. + * The dialog that is shown when the error + * "Your local changes to the following files would be overwritten by merge/checkout" + * happens. + * Displays the list of these files and proposes to make a "smart" merge or checkout. * * @author Kirill Likhodedov */ -// TODO "don't ask again" option -class GitWouldBeOverwrittenByCheckoutDialog extends DialogWrapper { +class GitSmartOperationDialog extends DialogWrapper { - public static final int SMART_CHECKOUT = OK_EXIT_CODE; - public static final int FORCE_CHECKOUT_EXIT_CODE = NEXT_USER_EXIT_CODE; + public static final int SMART_EXIT_CODE = OK_EXIT_CODE; + public static final int FORCE_EXIT_CODE = NEXT_USER_EXIT_CODE; private final Project myProject; private final List myChanges; + @NotNull private final String myCapitalizedOperationTitle; + private final boolean myForceButton; /** - * @return true if smart checkout has to be performed, false if user doesn't want to checkout. + * Shows the dialog with the list of local changes preventing merge/checkout and returns the dialog exit code. */ - static int showAndGetAnswer(@NotNull final Project project, @NotNull final List changes) { + static int showAndGetAnswer(@NotNull final Project project, @NotNull final List changes, @NotNull final String operationTitle, + final boolean forceButton) { final AtomicInteger exitCode = new AtomicInteger(); UIUtil.invokeAndWaitIfNeeded(new Runnable() { @Override public void run() { - GitWouldBeOverwrittenByCheckoutDialog dialog = new GitWouldBeOverwrittenByCheckoutDialog(project, changes); + GitSmartOperationDialog dialog = new GitSmartOperationDialog(project, changes, operationTitle, forceButton); DialogManager.getInstance(project).showDialog(dialog); exitCode.set(dialog.getExitCode()); } @@ -61,19 +66,25 @@ class GitWouldBeOverwrittenByCheckoutDialog extends DialogWrapper { return exitCode.get(); } - private GitWouldBeOverwrittenByCheckoutDialog(@NotNull Project project, @NotNull List changes) { + private GitSmartOperationDialog(@NotNull Project project, @NotNull List changes, @NotNull String operationTitle, + boolean forceButton) { super(project); myProject = project; myChanges = changes; - setOKButtonText("Smart checkout"); - setCancelButtonText("Don't checkout"); + myCapitalizedOperationTitle = StringUtil.capitalize(operationTitle); + myForceButton = forceButton; + setOKButtonText("Smart " + myCapitalizedOperationTitle); + setCancelButtonText("Don't " + myCapitalizedOperationTitle); getCancelAction().putValue(FOCUSED_ACTION, Boolean.TRUE); init(); } @Override protected Action[] createLeftSideActions() { - return new Action[] {new ForceCheckoutAction() }; + if (myForceButton) { + return new Action[] {new ForceCheckoutAction(myCapitalizedOperationTitle) }; + } + return new Action[0]; } @Override @@ -94,19 +105,19 @@ class GitWouldBeOverwrittenByCheckoutDialog extends DialogWrapper { @Override protected String getDimensionServiceKey() { - return GitWouldBeOverwrittenByCheckoutDialog.class.getName(); + return GitSmartOperationDialog.class.getName(); } private class ForceCheckoutAction extends AbstractAction { - ForceCheckoutAction() { - super("Force checkout"); + ForceCheckoutAction(@NotNull String operationTitle) { + super("Force " + operationTitle); } @Override public void actionPerformed(ActionEvent e) { - close(FORCE_CHECKOUT_EXIT_CODE); + close(FORCE_EXIT_CODE); } } diff --git a/plugins/git4idea/src/git4idea/commands/Git.java b/plugins/git4idea/src/git4idea/commands/Git.java index c52978d918dd..27f94bf775b9 100644 --- a/plugins/git4idea/src/git4idea/commands/Git.java +++ b/plugins/git4idea/src/git4idea/commands/Git.java @@ -131,6 +131,19 @@ public class Git { return run(handler, true); } + @NotNull + public static GitCommandResult merge(@NotNull GitRepository repository, @NotNull String branchToMerge, + @NotNull GitLineHandlerListener... listeners) { + final GitLineHandler mergeHandler = new GitLineHandler(repository.getProject(), repository.getRoot(), GitCommand.MERGE); + mergeHandler.setSilent(false); + mergeHandler.addParameters(branchToMerge); + for (GitLineHandlerListener listener : listeners) { + mergeHandler.addLineListener(listener); + } + return run(mergeHandler); + } + + /** * {@code git checkout <reference>}
* {@code git checkout -b <newBranch> <reference>} @@ -225,6 +238,13 @@ public class Git { return run(h); } + @NotNull + public static GitCommandResult hardReset(@NotNull GitRepository repository, @NotNull String revision) { + final GitLineHandler handler = new GitLineHandler(repository.getProject(), repository.getRoot(), GitCommand.RESET); + handler.addParameters("--hard", revision); + return run(handler); + } + /** * Returns the last (tip) commit on the given branch.
* {@code git rev-list -1 } diff --git a/plugins/git4idea/src/git4idea/commands/GitMessageWithFilesDetector.java b/plugins/git4idea/src/git4idea/commands/GitMessageWithFilesDetector.java index 4b597df64bd7..a3c8910ab94b 100644 --- a/plugins/git4idea/src/git4idea/commands/GitMessageWithFilesDetector.java +++ b/plugins/git4idea/src/git4idea/commands/GitMessageWithFilesDetector.java @@ -40,6 +40,9 @@ public class GitMessageWithFilesDetector implements GitLineHandlerListener { private final VirtualFile myRoot; public enum Event { + LOCAL_CHANGES_OVERWRITTEN_BY_MERGE( + "Your local changes to the following files would be overwritten by merge", + "commit your changes or stash them before"), LOCAL_CHANGES_OVERWRITTEN_BY_CHECKOUT( "Your local changes to the following files would be overwritten by checkout", "commit your changes or stash them before"), diff --git a/plugins/git4idea/src/git4idea/commands/GitSimpleEventDetector.java b/plugins/git4idea/src/git4idea/commands/GitSimpleEventDetector.java index 34c612e3b466..216db2ad3aa8 100644 --- a/plugins/git4idea/src/git4idea/commands/GitSimpleEventDetector.java +++ b/plugins/git4idea/src/git4idea/commands/GitSimpleEventDetector.java @@ -26,8 +26,11 @@ public class GitSimpleEventDetector implements GitLineHandlerListener { private boolean myHappened; public enum Event { - UNMERGED("you need to resolve your current index first"), - BRANCH_NOT_FULLY_MERGED("is not fully merged"); + UNMERGED_PREVENTING_CHECKOUT("you need to resolve your current index first"), + UNMERGED_PREVENTING_MERGE("is not possible because you have unmerged files"), + BRANCH_NOT_FULLY_MERGED("is not fully merged"), + MERGE_CONFLICT("Automatic merge failed; fix conflicts and then commit the result"), + ALREADY_UP_TO_DATE("Already up-to-date"); private final String myDetectionString; diff --git a/plugins/git4idea/src/git4idea/merge/GitConflictResolver.java b/plugins/git4idea/src/git4idea/merge/GitConflictResolver.java index c1987199ce7d..351bb674b5cf 100644 --- a/plugins/git4idea/src/git4idea/merge/GitConflictResolver.java +++ b/plugins/git4idea/src/git4idea/merge/GitConflictResolver.java @@ -118,7 +118,6 @@ public class GitConflictResolver { * the method shows a notification and returns {@code false}. *

* - * @param roots Git repositories to look for unmerged files. * @return {@code true} if there is nothing to merge anymore, {@code false} if unmerged files remain or in the case of error. */ public final boolean merge() { @@ -221,6 +220,11 @@ public class GitConflictResolver { } + @NotNull + protected NotificationListener getResolveLinkListener() { + return new ResolveNotificationListener(); + } + private class ResolveNotificationListener implements NotificationListener { @Override public void hyperlinkUpdate(@NotNull final Notification notification, @NotNull HyperlinkEvent event) { if (event.getEventType() == HyperlinkEvent.EventType.ACTIVATED && event.getDescription().equals("resolve")) { diff --git a/plugins/git4idea/src/git4idea/merge/GitMergeCommittingConflictResolver.java b/plugins/git4idea/src/git4idea/merge/GitMergeCommittingConflictResolver.java new file mode 100644 index 000000000000..4c48ab6dd0dd --- /dev/null +++ b/plugins/git4idea/src/git4idea/merge/GitMergeCommittingConflictResolver.java @@ -0,0 +1,51 @@ +/* + * Copyright 2000-2012 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.merge; + +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vcs.VcsException; +import com.intellij.openapi.vfs.VirtualFile; + +import java.util.Collection; + +/** + * Conflict resolver that makes a merge commit after all conflicts are resolved. + * + * @author Kirill Likhodedov + */ +public class GitMergeCommittingConflictResolver extends GitConflictResolver { + private final Collection myMergingRoots; + private final boolean myRefreshAfterCommit; + private final GitMerger myMerger; + + public GitMergeCommittingConflictResolver(Project project, GitMerger merger, Collection mergingRoots, Params params, + boolean refreshAfterCommit) { + super(project, mergingRoots, params); + myMerger = merger; + myMergingRoots = mergingRoots; + myRefreshAfterCommit = refreshAfterCommit; + } + + @Override protected boolean proceedAfterAllMerged() throws VcsException { + myMerger.mergeCommit(myMergingRoots); + if (myRefreshAfterCommit) { + for (VirtualFile root : myMergingRoots) { + root.refresh(true, true); + } + } + return true; + } +} diff --git a/plugins/git4idea/src/git4idea/stash/GitStashChangesSaver.java b/plugins/git4idea/src/git4idea/stash/GitStashChangesSaver.java index b369b4455717..9ce2fe859bde 100644 --- a/plugins/git4idea/src/git4idea/stash/GitStashChangesSaver.java +++ b/plugins/git4idea/src/git4idea/stash/GitStashChangesSaver.java @@ -67,21 +67,24 @@ public class GitStashChangesSaver extends GitChangesSaver { } @Override - protected void load(ContinuationContext context) { + protected void load(@NotNull ContinuationContext context) { + try { + load(); + } + catch (VcsException e) { + context.handleException(e); + } + } + + public void load() throws VcsException { for (VirtualFile root : myStashedRoots) { - try { - loadRoot(root); - } - catch (VcsException e) { - context.handleException(e); - return; - } + loadRoot(root); } // we'll refresh more but this way we needn't compute what files under roots etc LocalFileSystem.getInstance().refreshIoFiles(myChangeManager.getAffectedPaths()); } - @Override + @Override protected boolean wereChangesSaved() { return !myStashedRoots.isEmpty(); } diff --git a/plugins/git4idea/src/git4idea/ui/branch/GitBranchPopupActions.java b/plugins/git4idea/src/git4idea/ui/branch/GitBranchPopupActions.java index efdd34f94929..ba4d118967ea 100644 --- a/plugins/git4idea/src/git4idea/ui/branch/GitBranchPopupActions.java +++ b/plugins/git4idea/src/git4idea/ui/branch/GitBranchPopupActions.java @@ -53,7 +53,6 @@ class GitBranchPopupActions { ActionGroup createActions(@Nullable DefaultActionGroup toInsert) { DefaultActionGroup popupGroup = new DefaultActionGroup(null, false); - //popupGroup.addAction(new CurrentBranchAction(GitBranchUiUtil.getDisplayableBranchText(myRepository), "in root " + GitUIUtil.getShortRepositoryName(myRepository))); popupGroup.addAction(new NewBranchAction(myProject, Collections.singletonList(myRepository), myRepository)); popupGroup.addAction(new CheckoutRevisionActions(myProject, myRepository)); @@ -80,23 +79,6 @@ class GitBranchPopupActions { return popupGroup; } - ///** - // * "Current branch:" item which is disabled and is just a label to display the current branch. - // */ - //static class CurrentBranchAction extends DumbAwareAction { - // CurrentBranchAction(@NotNull String currentBranchName, @Nullable String rootName) { - // super("", String.format("Current branch is %s %s", currentBranchName, rootName), null); - // getTemplatePresentation().setText("Current Branch: " + currentBranchName, false); // no mnemonics - // } - // - // @Override public void actionPerformed(AnActionEvent e) { - // } - // - // @Override public void update(AnActionEvent e) { - // e.getPresentation().setEnabled(false); // this action works as a label - // } - //} - static class NewBranchAction extends DumbAwareAction { private final Project myProject; private final List myRepositories; @@ -205,6 +187,7 @@ class GitBranchPopupActions { new CheckoutAction(myProject, myRepositories, myBranchName, mySelectedRepository), new CheckoutAsNewBranch(myProject, myRepositories, myBranchName, mySelectedRepository), new CompareAction(myProject, myRepositories, myBranchName, mySelectedRepository), + new MergeAction(myProject, myRepositories, myBranchName, mySelectedRepository), new DeleteAction(myProject, myRepositories, myBranchName, mySelectedRepository) }; } @@ -308,6 +291,7 @@ class GitBranchPopupActions { return new AnAction[] { new CheckoutRemoteBranchAction(myProject, myRepositories, myBranchName, mySelectedRepository), new CompareAction(myProject, myRepositories, myBranchName, mySelectedRepository), + new MergeAction(myProject, myRepositories, myBranchName, mySelectedRepository), }; } @@ -342,7 +326,6 @@ class GitBranchPopupActions { return myRemoteBranchName.substring(slashPosition+1); } } - } private static class CompareAction extends DumbAwareAction { @@ -352,10 +335,8 @@ class GitBranchPopupActions { private final String myBranchName; private final GitRepository mySelectedRepository; - public CompareAction(@NotNull Project project, - @NotNull List repositories, - @NotNull String branchName, - GitRepository selectedRepository) { + public CompareAction(@NotNull Project project, @NotNull List repositories, @NotNull String branchName, + @NotNull GitRepository selectedRepository) { super("Compare"); myProject = project; myRepositories = repositories; @@ -369,4 +350,27 @@ class GitBranchPopupActions { } } + + private static class MergeAction extends DumbAwareAction { + + private final Project myProject; + private final List myRepositories; + private final String myBranchName; + private final GitRepository mySelectedRepository; + + public MergeAction(@NotNull Project project, @NotNull List repositories, @NotNull String branchName, + @NotNull GitRepository selectedRepository) { + super("Merge"); + myProject = project; + myRepositories = repositories; + myBranchName = branchName; + mySelectedRepository = selectedRepository; + } + + @Override + public void actionPerformed(AnActionEvent e) { + new GitBranchOperationsProcessor(myProject, myRepositories, mySelectedRepository).merge(myBranchName); + } + + } } diff --git a/plugins/git4idea/src/git4idea/update/GitComplexProcess.java b/plugins/git4idea/src/git4idea/update/GitComplexProcess.java index 612133003765..607f6a422370 100644 --- a/plugins/git4idea/src/git4idea/update/GitComplexProcess.java +++ b/plugins/git4idea/src/git4idea/update/GitComplexProcess.java @@ -15,14 +15,12 @@ */ package git4idea.update; -import com.intellij.ide.SaveAndSyncHandler; -import com.intellij.openapi.fileEditor.FileDocumentManager; import com.intellij.openapi.project.Project; -import com.intellij.openapi.project.ex.ProjectManagerEx; import com.intellij.openapi.vcs.changes.ChangeListManager; import com.intellij.util.continuation.*; import git4idea.repo.GitRepository; import git4idea.repo.GitRepositoryManager; +import git4idea.util.GitFreezingProcess; /** * This class executes a Git task, surrounding it with a couple of preparation and completion tasks, such as: @@ -50,16 +48,12 @@ public class GitComplexProcess { private final Operation myOperation; private final String myFreezeReason; - private final ProjectManagerEx myProjectManager; private final GitRepositoryManager myRepositoryManager; private final ChangeListManager myChangeListManager; private final TaskDescriptor BLOCK = new TaskDescriptor("", Where.AWT) { @Override public void run(ContinuationContext context) { - myProjectManager.blockReloadingProjectOnExternalChanges(); - FileDocumentManager.getInstance().saveAllDocuments(); - SaveAndSyncHandler.getInstance().blockSaveOnFrameDeactivation(); - SaveAndSyncHandler.getInstance().blockSyncOnFrameActivation(); + GitFreezingProcess.saveAndBlock(); } }; @@ -77,9 +71,7 @@ public class GitComplexProcess { private final TaskDescriptor UNBLOCK = new TaskDescriptor("", Where.AWT) { @Override public void run(ContinuationContext context) { - myProjectManager.unblockReloadingProjectOnExternalChanges(); - SaveAndSyncHandler.getInstance().unblockSaveOnFrameDeactivation(); - SaveAndSyncHandler.getInstance().unblockSyncOnFrameActivation(); + GitFreezingProcess.unblock(); } @Override public boolean isHaveMagicCure() { @@ -99,7 +91,6 @@ public class GitComplexProcess { myOperation = operation; myFreezeReason = "Local changes are not available until Git " + myTitle + " is finished."; - myProjectManager = ProjectManagerEx.getInstanceEx(); myRepositoryManager = GitRepositoryManager.getInstance(project); myChangeListManager = ChangeListManager.getInstance(myProject); diff --git a/plugins/git4idea/src/git4idea/update/GitUpdateProcess.java b/plugins/git4idea/src/git4idea/update/GitUpdateProcess.java index 6f3b1fff17c2..6f4c81c8aeb6 100644 --- a/plugins/git4idea/src/git4idea/update/GitUpdateProcess.java +++ b/plugins/git4idea/src/git4idea/update/GitUpdateProcess.java @@ -31,6 +31,7 @@ import git4idea.GitBranch; import git4idea.GitVcs; import git4idea.branch.GitBranchPair; import git4idea.merge.GitConflictResolver; +import git4idea.merge.GitMergeCommittingConflictResolver; import git4idea.merge.GitMerger; import git4idea.rebase.GitRebaser; import git4idea.stash.GitChangesSaver; @@ -297,7 +298,7 @@ public class GitUpdateProcess { GitConflictResolver.Params params = new GitConflictResolver.Params(); params.setErrorNotificationTitle("Can't update"); params.setMergeDescription("You have unfinished merge. These conflicts must be resolved before update."); - return !new MergeCommittingConflictResolver(myProject, myMerger, mergingRoots, params).merge(); + return !new GitMergeCommittingConflictResolver(myProject, myMerger, mergingRoots, params, false).merge(); } /** @@ -308,7 +309,7 @@ public class GitUpdateProcess { GitConflictResolver.Params params = new GitConflictResolver.Params(); params.setErrorNotificationTitle("Can't update"); params.setMergeDescription("Unmerged files detected. These conflicts must be resolved before update."); - return !new MergeCommittingConflictResolver(myProject, myMerger, myRoots, params).merge(); + return !new GitMergeCommittingConflictResolver(myProject, myMerger, myRoots, params, false).merge(); } /** @@ -338,22 +339,4 @@ public class GitUpdateProcess { } }.merge(); } - - // conflict resolver that makes a merge commit after all conflicts are resolved - private static class MergeCommittingConflictResolver extends GitConflictResolver { - private final Collection myMergingRoots; - private final GitMerger myMerger; - - public MergeCommittingConflictResolver(Project project, GitMerger merger, Collection mergingRoots, Params params) { - super(project, mergingRoots, params); - myMerger = merger; - myMergingRoots = mergingRoots; - } - - @Override protected boolean proceedAfterAllMerged() throws VcsException { - myMerger.mergeCommit(myMergingRoots); - return true; - } - } - } diff --git a/plugins/git4idea/src/git4idea/util/GitFreezingProcess.java b/plugins/git4idea/src/git4idea/util/GitFreezingProcess.java new file mode 100644 index 000000000000..1d3ee9f838a2 --- /dev/null +++ b/plugins/git4idea/src/git4idea/util/GitFreezingProcess.java @@ -0,0 +1,118 @@ +/* + * Copyright 2000-2012 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.util; + +import com.intellij.ide.SaveAndSyncHandler; +import com.intellij.openapi.fileEditor.FileDocumentManager; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.project.ex.ProjectManagerEx; +import com.intellij.openapi.vcs.changes.ChangeListManager; +import com.intellij.util.continuation.GatheringContinuationContext; +import com.intellij.util.ui.UIUtil; +import org.jetbrains.annotations.NotNull; + +import java.util.concurrent.atomic.AtomicReference; + +/** + * Executes an action surrounding it with freezing-unfreezing operations. + * It is a simple linear alternative to {@link git4idea.update.GitComplexProcess} performing tasks in a single method instead of + * using continuations logic. + * + * @author Kirill Likhodedov + */ +public class GitFreezingProcess { + + @NotNull private final String myOperationTitle; + @NotNull private final Runnable myRunnable; + @NotNull private final ChangeListManager myChangeListManager; + + public GitFreezingProcess(@NotNull Project project, @NotNull String operationTitle, @NotNull Runnable runnable) { + myOperationTitle = operationTitle; + myRunnable = runnable; + myChangeListManager = ChangeListManager.getInstance(project); + } + + public void execute() { + try { + saveAndBlockInAwt(); + freeze(); + try { + myRunnable.run(); + } + finally { + unfreeze(); + } + } + finally { + unblockInAwt(); + } + } + + public static void saveAndBlock() { + ProjectManagerEx.getInstanceEx().blockReloadingProjectOnExternalChanges(); + FileDocumentManager.getInstance().saveAllDocuments(); + SaveAndSyncHandler.getInstance().blockSaveOnFrameDeactivation(); + SaveAndSyncHandler.getInstance().blockSyncOnFrameActivation(); + } + + private static void saveAndBlockInAwt() { + final AtomicReference exception = new AtomicReference(); + // if an error happens, let it be thrown there (in awt) + throw it here to call unblock if anything has already been blocked + UIUtil.invokeAndWaitIfNeeded(new Runnable() { + @Override + public void run() { + try { + saveAndBlock(); + } + catch (Throwable t) { + RuntimeException re = new RuntimeException(t); + exception.set(re); + throw re; + } + } + }); + + RuntimeException re = exception.get(); + if (re != null) { + throw re; + } + } + + private static void unblockInAwt() { + UIUtil.invokeAndWaitIfNeeded(new Runnable() { + @Override + public void run() { + unblock(); + } + }); + } + + public static void unblock() { + ProjectManagerEx.getInstanceEx().unblockReloadingProjectOnExternalChanges(); + SaveAndSyncHandler.getInstance().unblockSaveOnFrameDeactivation(); + SaveAndSyncHandler.getInstance().unblockSyncOnFrameActivation(); + } + + private void freeze() { + myChangeListManager.freeze(new GatheringContinuationContext(), + "Local changes are not available until Git " + myOperationTitle + " is finished."); + } + + private void unfreeze() { + myChangeListManager.letGo(); + } + +} diff --git a/plugins/git4idea/src/git4idea/util/GitPreservingProcess.java b/plugins/git4idea/src/git4idea/util/GitPreservingProcess.java new file mode 100644 index 000000000000..4162719c18f5 --- /dev/null +++ b/plugins/git4idea/src/git4idea/util/GitPreservingProcess.java @@ -0,0 +1,169 @@ +/* + * Copyright 2000-2012 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.util; + +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.progress.ProgressIndicator; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.Clock; +import com.intellij.openapi.util.Computable; +import com.intellij.openapi.util.text.StringUtil; +import com.intellij.openapi.vcs.VcsException; +import com.intellij.openapi.vcs.history.VcsRevisionNumber; +import com.intellij.openapi.vcs.merge.MergeDialogCustomizer; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.util.text.DateFormatUtil; +import git4idea.GitUtil; +import git4idea.NotificationManager; +import git4idea.merge.GitConflictResolver; +import git4idea.repo.GitRepository; +import git4idea.stash.GitStashChangesSaver; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Collection; + +import static com.intellij.openapi.util.text.StringUtil.join; + +/** + * Executes a Git operation on a number of repositories surrounding it by stash-unstash procedure. + * I.e. stashes changes, executes the operation and then unstashes it. + * + * @author Kirill Likhodedov + */ +public class GitPreservingProcess { + + private static final Logger LOG = Logger.getInstance(GitPreservingProcess.class); + + @NotNull private final Project myProject; + @NotNull private final Collection myRepositories; + @NotNull private final String myOperationTitle; + @NotNull private final String myDestinationName; + @NotNull private final ProgressIndicator myProgressIndicator; + @NotNull private final Runnable myOperation; + @NotNull private final String myStashMessage; + + private GitStashChangesSaver mySaver; + private boolean myLoaded; + private final Object LOAD_LOCK = new Object(); + + public GitPreservingProcess(@NotNull Project project, @NotNull Collection repositories, + @NotNull String operationTitle, @NotNull String destinationName, + @NotNull ProgressIndicator indicator, @NotNull Runnable operation) { + myProject = project; + myRepositories = repositories; + myOperationTitle = operationTitle; + myDestinationName = destinationName; + myProgressIndicator = indicator; + myOperation = operation; + myStashMessage = String.format("%s %s at %s", StringUtil.capitalize(myOperationTitle), myDestinationName, + DateFormatUtil.formatDateTime(Clock.getTime())); + } + + public void execute() { + execute(null); + } + + public void execute(@Nullable final Computable autoLoadDecision) { + Runnable operation = new Runnable() { + @Override + public void run() { + mySaver = configureSaver(); + boolean savedSuccessfully = save(); + if (savedSuccessfully) { + try { + myOperation.run(); + } + finally { + if (autoLoadDecision == null || autoLoadDecision.compute()) { + load(); + } + } + } + } + }; + + new GitFreezingProcess(myProject, myOperationTitle, operation).execute(); + } + + /** + * Configures the saver, actually notifications and texts in the GitConflictResolver used inside. + */ + private GitStashChangesSaver configureSaver() { + GitStashChangesSaver saver = new GitStashChangesSaver(myProject, myProgressIndicator, myStashMessage); + MergeDialogCustomizer mergeDialogCustomizer = new MergeDialogCustomizer() { + @Override + public String getMultipleFileMergeDescription(Collection files) { + return String.format( + "Uncommitted changes that were saved before %s have conflicts with files from %s", + myOperationTitle, myDestinationName); + } + + @Override + public String getLeftPanelTitle(VirtualFile file) { + return "Uncommitted changes from stash"; + } + + @Override + public String getRightPanelTitle(VirtualFile file, VcsRevisionNumber lastRevisionNumber) { + return String.format("Changes from %s", myDestinationName); + } + }; + + GitConflictResolver.Params params = new GitConflictResolver.Params(). + setReverse(true). + setMergeDialogCustomizer(mergeDialogCustomizer). + setErrorNotificationTitle("Local changes were not restored"); + + saver.setConflictResolverParams(params); + return saver; + } + + /** + * Saves local changes. In case of error shows a notification and returns false. + */ + private boolean save() { + try { + mySaver.saveLocalChanges(GitUtil.getRoots(myRepositories)); + return true; + } catch (VcsException e) { + LOG.info("Couldn't save local changes", e); + NotificationManager.getInstance(myProject).notifyError( + "Couldn't save uncommitted changes.", + String.format("Tried to save uncommitted changes in stash before %s, but failed with an error.
%s", + myOperationTitle, join(e.getMessages()))); + return false; + } + } + + public void load() { + synchronized (LOAD_LOCK) { + if (myLoaded) { + return; + } + try { + mySaver.load(); + myLoaded = true; + } + catch (VcsException e) { + LOG.info("Couldn't load local changes", e); + NotificationManager.getInstance(myProject).notifyError("Couldn't restore uncommitted changes", + String.format("Tried to unstash uncommitted changes, but failed with error.
%s",join(e.getMessages()))); + } + } + } + +} diff --git a/plugins/git4idea/tests/git4idea/branch/GitBranchOperationsTest.java b/plugins/git4idea/tests/git4idea/branch/GitBranchOperationsTest.java index 2209fdc834d9..958213bbd41f 100644 --- a/plugins/git4idea/tests/git4idea/branch/GitBranchOperationsTest.java +++ b/plugins/git4idea/tests/git4idea/branch/GitBranchOperationsTest.java @@ -141,14 +141,14 @@ public class GitBranchOperationsTest extends AbstractVcsTestCase { public void create_new_branch_with_unmerged_files_in_first_repo_should_show_notification() throws Exception { GitTestScenarioGenerator.prepareUnmergedFiles(myUltimate); doCheckoutNewBranch(); - assertNotify(NotificationType.ERROR, GitBranchOperation.UNMERGED_FILES_ERROR_NOTIFICATION_DESCRIPTION); + assertNotify(NotificationType.ERROR, unmergedFilesErrorNotificationDescription("checkout")); } - + @Test public void create_new_branch_with_unmerged_files_in_second_repo_should_propose_to_rollback() throws Exception { GitTestScenarioGenerator.prepareUnmergedFiles(myCommunity, myContrib); doCheckoutNewBranch(); - assertMessage(GitBranchOperation.UNMERGED_FILES_ERROR_TITLE); + assertMessage(unmergedFilesErrorTitle("checkout")); } @Test @@ -156,7 +156,7 @@ public class GitBranchOperationsTest extends AbstractVcsTestCase { GitTestScenarioGenerator.prepareUnmergedFiles(myCommunity, myContrib); myMessageManager.nextAnswer(Messages.OK); doCheckoutNewBranch(); - assertMessage(GitBranchOperation.UNMERGED_FILES_ERROR_TITLE); + assertMessage(unmergedFilesErrorTitle("checkout")); assertBranch("master"); assertTrue(!branch(myUltimate).contains(NEW_BRANCH)); } @@ -166,53 +166,53 @@ public class GitBranchOperationsTest extends AbstractVcsTestCase { GitTestScenarioGenerator.prepareUnmergedFiles(myCommunity, myContrib); myMessageManager.nextAnswer(Messages.CANCEL); doCheckoutNewBranch(); - assertMessage(GitBranchOperation.UNMERGED_FILES_ERROR_TITLE); + assertMessage(unmergedFilesErrorTitle("checkout")); assertBranch(myUltimate, NEW_BRANCH); assertBranch(myCommunity, MASTER); assertBranch(myContrib, MASTER); } - + @Test public void checkout_without_problems() throws Exception { - prepareBranchForSimpleCheckout(); + prepareBranchWithCommit("feature"); doCheckout("feature", null); assertNotify(NotificationType.INFORMATION, "Checked out feature"); } - + @Test public void checkout_with_unmerged_files_in_first_repo_should_show_notification() throws Exception { - prepareBranchForSimpleCheckout(); + prepareBranchWithCommit("feature"); GitTestScenarioGenerator.prepareUnmergedFiles(myUltimate); doCheckout("feature", null); - assertNotify(NotificationType.ERROR, GitBranchOperation.UNMERGED_FILES_ERROR_NOTIFICATION_DESCRIPTION); + assertNotify(NotificationType.ERROR, unmergedFilesErrorNotificationDescription("checkout")); } - + @Test public void checkout_with_unmerged_file_in_second_repo_should_propose_to_rollback() throws Exception { - prepareBranchForSimpleCheckout(); + prepareBranchWithCommit("feature"); GitTestScenarioGenerator.prepareUnmergedFiles(myCommunity); doCheckout("feature", null); - assertMessage(GitBranchOperation.UNMERGED_FILES_ERROR_TITLE); + assertMessage(unmergedFilesErrorTitle("checkout")); } @Test public void rollback_checkout_should_return_to_previous_branch() throws Exception { - prepareBranchForSimpleCheckout(); + prepareBranchWithCommit("feature"); GitTestScenarioGenerator.prepareUnmergedFiles(myCommunity); myMessageManager.nextAnswer(Messages.OK); doCheckout("feature", null); - assertMessage(GitBranchOperation.UNMERGED_FILES_ERROR_TITLE); + assertMessage(unmergedFilesErrorTitle("checkout")); assertBranch("master"); } @Test public void deny_rollback_checkout_should_do_nothing() throws Exception { - prepareBranchForSimpleCheckout(); + prepareBranchWithCommit("feature"); GitTestScenarioGenerator.prepareUnmergedFiles(myCommunity); myMessageManager.nextAnswer(Messages.CANCEL); doCheckout("feature", null); - assertMessage(GitBranchOperation.UNMERGED_FILES_ERROR_TITLE); + assertMessage(unmergedFilesErrorTitle("checkout")); assertBranch(myUltimate, "feature"); assertBranch(myCommunity, "master"); assertBranch(myContrib, "master"); @@ -220,21 +220,30 @@ public class GitBranchOperationsTest extends AbstractVcsTestCase { @Test public void checkout_with_untracked_files_overwritten_by_checkout_in_first_repo_should_show_notification() throws Exception { + test_untracked_files_overwritten_by_in_first_repo(true); + } + + private void test_untracked_files_overwritten_by_in_first_repo(boolean checkout) throws Exception { prepareUntrackedFilesAndBranchWithSameTrackedFiles(myUltimate); branch(myCommunity, "feature"); branch(myContrib, "feature"); - doCheckout("feature", null); - assertNotify(NotificationType.ERROR, UntrackedFilesNotifier.createUntrackedFilesOverwrittenDescription("checkout", false)); + doCheckoutOrMerge(checkout, "feature"); + String operation = checkout ? "checkout" : "merge"; + assertNotify(NotificationType.ERROR, UntrackedFilesNotifier.createUntrackedFilesOverwrittenDescription(operation, false)); } @Test public void checkout_with_untracked_files_overwritten_by_checkout_in_second_repo_should_show_rollback_proposal_with_file_list() throws Exception { + test_checkout_with_untracked_files_overwritten_by_in_second_repo(true); + } + + public void test_checkout_with_untracked_files_overwritten_by_in_second_repo(boolean checkout) throws Exception { prepareUntrackedFilesAndBranchWithSameTrackedFiles(myCommunity); branch(myUltimate, "feature"); branch(myContrib, "feature"); - Class gitCheckoutOperationClass = Class.forName("git4idea.branch.GitCheckoutOperation"); + Class gitCheckoutOperationClass = Class.forName("git4idea.branch.GitBranchOperation"); Class[] classes = gitCheckoutOperationClass.getDeclaredClasses(); Class untrackedFilesDialogClass = null; for (Class aClass : classes) { @@ -243,7 +252,7 @@ public class GitBranchOperationsTest extends AbstractVcsTestCase { } } assertNotNull(untrackedFilesDialogClass); - + final AtomicBoolean dialogShown = new AtomicBoolean(); final Class finalUntrackedFilesDialogClass = untrackedFilesDialogClass; myDialogManager.registerDialogHandler(untrackedFilesDialogClass, new TestDialogHandler() { @@ -256,43 +265,36 @@ public class GitBranchOperationsTest extends AbstractVcsTestCase { } }); - doCheckout("feature", null); + doCheckoutOrMerge(checkout, "feature"); assertTrue(dialogShown.get()); } @Test public void checkout_with_local_changes_overwritten_by_checkout_should_show_smart_checkout_dialog() throws Exception { + test_operation_with_local_changes_overwritten_by_should_show_smart_checkout_dialog(true); + } + + public void test_operation_with_local_changes_overwritten_by_should_show_smart_checkout_dialog(boolean checkout) throws Exception { prepareLocalChangesAndBranchWithSameModifiedFilesWithoutConflicts(myUltimate); branch(myCommunity, "feature"); branch(myContrib, "feature"); final AtomicBoolean dialogShown = new AtomicBoolean(); - myDialogManager.registerDialogHandler(GitWouldBeOverwrittenByCheckoutDialog.class, new TestDialogHandler() { + myDialogManager.registerDialogHandler(GitSmartOperationDialog.class, new TestDialogHandler() { @Override - public int handleDialog(GitWouldBeOverwrittenByCheckoutDialog dialog) { + public int handleDialog(GitSmartOperationDialog dialog) { dialogShown.set(true); return DialogWrapper.CANCEL_EXIT_CODE; } }); - doCheckout("feature", null); + doCheckoutOrMerge(checkout, "feature"); assertTrue(dialogShown.get()); } @Test public void agree_to_smart_checkout_should_smart_checkout() throws Exception { - prepareLocalChangesAndBranchWithSameModifiedFilesWithoutConflicts(myUltimate); - prepareLocalChangesAndBranchWithSameModifiedFilesWithoutConflicts(myCommunity); - prepareLocalChangesAndBranchWithSameModifiedFilesWithoutConflicts(myContrib); - myDialogManager.registerDialogHandler(GitWouldBeOverwrittenByCheckoutDialog.class, - new TestDialogHandler() { - @Override - public int handleDialog(GitWouldBeOverwrittenByCheckoutDialog dialog) { - return DialogWrapper.OK_EXIT_CODE; - } - }); - - doCheckout("feature", null); + prepare_agree_to_smart_operation(true); assertBranch("feature"); for (GitRepository repository : myRepositories) { refresh(repository); @@ -301,53 +303,81 @@ public class GitBranchOperationsTest extends AbstractVcsTestCase { } } + public void prepare_agree_to_smart_operation(boolean checkout) throws Exception { + prepareLocalChangesAndBranchWithSameModifiedFilesWithoutConflicts(myUltimate); + prepareLocalChangesAndBranchWithSameModifiedFilesWithoutConflicts(myCommunity); + prepareLocalChangesAndBranchWithSameModifiedFilesWithoutConflicts(myContrib); + myDialogManager.registerDialogHandler(GitSmartOperationDialog.class, + new TestDialogHandler() { + @Override + public int handleDialog(GitSmartOperationDialog dialog) { + return DialogWrapper.OK_EXIT_CODE; + } + }); + + doCheckoutOrMerge(checkout, "feature"); + } + @Test public void deny_to_smart_checkout_in_first_repo_should_show_notification() throws Exception { + test_deny_to_smart_operation_in_first_repo_should_show_notification(true); + } + + public void test_deny_to_smart_operation_in_first_repo_should_show_notification(boolean checkout) throws Exception { prepareLocalChangesAndBranchWithSameModifiedFilesWithoutConflicts(myUltimate); branch(myCommunity, "feature"); branch(myContrib, "feature"); - myDialogManager.registerDialogHandler(GitWouldBeOverwrittenByCheckoutDialog.class, new TestDialogHandler() { + myDialogManager.registerDialogHandler(GitSmartOperationDialog.class, new TestDialogHandler() { @Override - public int handleDialog(GitWouldBeOverwrittenByCheckoutDialog dialog) { + public int handleDialog(GitSmartOperationDialog dialog) { return DialogWrapper.CANCEL_EXIT_CODE; } }); - doCheckout("feature", null); - assertNotify(NotificationType.ERROR, "Couldn't checkout feature", stripHtmlAndBreaks("Local changes would be overwritten by checkout." + - "Stash or commit them before checking out a branch.")); + doCheckoutOrMerge(checkout, "feature"); + String operation = checkout ? "checkout" : "merge"; + assertNotify(NotificationType.ERROR, "Couldn't " + operation + " feature", + stripHtmlAndBreaks("Local changes would be overwritten by " + operation + "." + "You should stash or commit them.")); assertBranch("master"); } @Test public void deny_to_smart_checkout_in_second_repo_should_show_rollback_proposal() throws Exception { + test_deny_to_smart_operation_in_second_repo_should_show_rollback_proposal(true); + } + + public void test_deny_to_smart_operation_in_second_repo_should_show_rollback_proposal(boolean checkout) throws Exception { prepareLocalChangesAndBranchWithSameModifiedFilesWithoutConflicts(myCommunity); branch(myUltimate, "feature"); branch(myContrib, "feature"); - myDialogManager.registerDialogHandler(GitWouldBeOverwrittenByCheckoutDialog.class, new TestDialogHandler() { + myDialogManager.registerDialogHandler(GitSmartOperationDialog.class, new TestDialogHandler() { @Override - public int handleDialog(GitWouldBeOverwrittenByCheckoutDialog dialog) { + public int handleDialog(GitSmartOperationDialog dialog) { return DialogWrapper.CANCEL_EXIT_CODE; } }); - doCheckout("feature", null); - assertMessage("Couldn't checkout feature", - "Local changes would be overwritten by checkout.
Stash or commit them before checking out a branch.
" + - "However checkout has succeeded for the following repositories:
" + - myUltimate.getPresentableUrl() + - "
You may rollback (checkout back to master) not to let branches diverge.", + doCheckoutOrMerge(checkout, "feature"); + String operationName = checkout ? "checkout" : "merge"; + String rollbackProposal = checkout ? + String.format(GitCheckoutOperation.ROLLBACK_PROPOSAL_FORMAT, "master") : + GitMergeOperation.ROLLBACK_PROPOSAL; + assertMessage("Couldn't " + operationName + " feature", + "Local changes would be overwritten by " + operationName + ".
You should stash or commit them.
" + + "However " + operationName + " has succeeded for the following repository:
" + + myUltimate.getPresentableUrl() + + "
" + rollbackProposal, "Rollback", "Don't rollback"); } - + @Test public void rollback_checkout_branch_as_new_branch_should_delete_branches() throws Exception { - prepareBranchForSimpleCheckout(); + prepareBranchWithCommit("feature"); GitTestScenarioGenerator.prepareUnmergedFiles(myCommunity); myMessageManager.nextAnswer(Messages.OK); doCheckout("feature", "newBranch"); - assertMessage(GitBranchOperation.UNMERGED_FILES_ERROR_TITLE); + assertMessage(unmergedFilesErrorTitle("checkout")); assertBranch("master"); for (GitRepository repository : myRepositories) { assertFalse(branch(repository).contains("newBranch"), "Branch newBranch wasn't deleted from repository " + getShortRepositoryName( @@ -372,14 +402,6 @@ public class GitBranchOperationsTest extends AbstractVcsTestCase { create(repository, "untracked.txt"); } - private void prepareBranchForSimpleCheckout() throws IOException { - for (GitRepository repository : myRepositories) { - checkout(repository, "-b", "feature"); - createAddCommit(repository, "feature_file.txt"); - checkout(repository, "master"); - } - } - @Test public void delete_branch_without_problems() throws Exception { for (GitRepository repository : myRepositories) { @@ -389,10 +411,10 @@ public class GitBranchOperationsTest extends AbstractVcsTestCase { doDeleteBranch("master1"); assertNotify(NotificationType.INFORMATION, "Deleted branch master1"); } - + @Test public void delete_unmerged_branch_should_show_dialog() throws Exception { - prepareUnmergedBranch(myUltimate, myCommunity, myContrib); + prepareBranchWithCommit("unmerged_branch", myUltimate, myCommunity, myContrib); final AtomicBoolean dialogShown = new AtomicBoolean(); myDialogManager.registerDialogHandler(GitBranchIsNotFullyMergedDialog.class, new TestDialogHandler() { @@ -405,41 +427,41 @@ public class GitBranchOperationsTest extends AbstractVcsTestCase { doDeleteBranch("unmerged_branch"); assertTrue(dialogShown.get()); } - + @Test public void ok_in_unmerged_branch_dialog_should_force_delete_branch() throws Exception { - prepareUnmergedBranch(myUltimate, myCommunity, myContrib); + prepareBranchWithCommit("unmerged_branch", myUltimate, myCommunity, myContrib); registerNotFullyMergedDialog(DialogWrapper.OK_EXIT_CODE); doDeleteBranch("unmerged_branch"); for (GitRepository repository : myRepositories) { assertTrue(!branch(repository).contains("unmerged_branch")); } } - + @Test public void cancel_in_unmerged_branch_dialog_in_first_repository_should_show_notification() throws Exception { - prepareUnmergedBranch(myUltimate, myContrib); + prepareBranchWithCommit("unmerged_branch", myUltimate, myContrib); branch(myCommunity, "unmerged_branch"); registerNotFullyMergedDialog(DialogWrapper.CANCEL_EXIT_CODE); doDeleteBranch("unmerged_branch"); - assertNotify(NotificationType.ERROR, "Branch unmerged_branch wasn't deleted", "This branch is not fully merged to master"); + assertNotify(NotificationType.ERROR, "Branch unmerged_branch wasn't deleted", "This branch is not fully merged to master."); } - + @Test public void cancel_in_unmerged_branch_dialog_in_not_first_repository_should_show_rollback_proposal() throws Exception { branch(myUltimate, "unmerged_branch"); - prepareUnmergedBranch(myCommunity, myContrib); + prepareBranchWithCommit("unmerged_branch", myCommunity, myContrib); registerNotFullyMergedDialog(DialogWrapper.CANCEL_EXIT_CODE); doDeleteBranch("unmerged_branch"); assertMessage(String.format("Branch %s wasn't deleted", "unmerged_branch")); } - + @Test public void rollback_delete_branch_should_recreate_branches() throws Exception { branch(myUltimate, "unmerged_branch"); - prepareUnmergedBranch(myCommunity); + prepareBranchWithCommit("unmerged_branch", myCommunity); branch(myContrib, "unmerged_branch"); registerNotFullyMergedDialog(DialogWrapper.CANCEL_EXIT_CODE); @@ -450,13 +472,13 @@ public class GitBranchOperationsTest extends AbstractVcsTestCase { assertTrue(branch(repository).contains("unmerged_branch")); } } - + @Test public void deny_rollback_delete_branch_should_do_nothing() throws Exception { branch(myUltimate, "unmerged_branch"); - prepareUnmergedBranch(myCommunity); + prepareBranchWithCommit("unmerged_branch", myCommunity); branch(myContrib, "unmerged_branch"); - + registerNotFullyMergedDialog(DialogWrapper.CANCEL_EXIT_CODE); myMessageManager.nextAnswer(Messages.CANCEL); doDeleteBranch("unmerged_branch"); @@ -466,6 +488,121 @@ public class GitBranchOperationsTest extends AbstractVcsTestCase { assertTrue(!branch(myUltimate).contains("unmerged_branch")); } + @Test + public void merge_simple_without_problems() throws Exception { + prepareBranchWithCommit("feature", myUltimate, myCommunity, myContrib); + doMerge("feature"); + assertNotify(NotificationType.INFORMATION, "Merged feature to master"); + + assertFile(myUltimate, "unmerged_branch_file", "content"); + assertFile(myCommunity, "unmerged_branch_file", "content"); + assertFile(myContrib, "unmerged_branch_file", "content"); + } + + private static void assertFile(GitRepository repository, String path, String content) throws IOException { + VirtualFile branchFile = repository.getRoot().findChild(path); + assertNotNull(branchFile); + assertTrue(branchFile.exists()); + assertEquals(new String(branchFile.contentsToByteArray()), content); + } + + @Test + public void merge_up_to_date_branch() throws Exception { + branch(myUltimate, "master2"); + branch(myCommunity, "master2"); + branch(myContrib, "master2"); + + doMerge("master2"); + + assertNotify(NotificationType.INFORMATION, "Already up-to-date."); + } + + @Test + public void merge_one_simple_and_other_up_to_date() throws Exception { + branch(myUltimate, "master2"); + branch(myContrib, "master2"); + prepareBranchWithCommit("master2", myCommunity); + + doMerge("master2"); + + assertNotify(NotificationType.INFORMATION, "Merged master2 to master"); + assertFile(myCommunity, "unmerged_branch_file", "content"); + assertNull(myUltimate.getRoot().findChild("unmerged_branch_file")); + } + + @Test + public void merge_with_unmerged_files_in_first_repo_should_show_notification() throws Exception { + prepareBranchWithCommit("feature"); + GitTestScenarioGenerator.prepareUnmergedFiles(myUltimate); + doMerge("feature"); + assertNotify(NotificationType.ERROR, unmergedFilesErrorNotificationDescription("merge")); + } + + @Test + public void merge_with_unmerged_files_in_second_repo_should_propose_to_rollback() throws Exception { + prepareBranchWithCommit("feature"); + GitTestScenarioGenerator.prepareUnmergedFiles(myCommunity); + doMerge("feature"); + assertMessage(unmergedFilesErrorTitle("merge")); + } + + @Test + public void rollback_merge_should_reset_merge() throws Exception { + prepareBranchWithCommit("feature"); + String ultimateTip = tip(myUltimate); + GitTestScenarioGenerator.prepareUnmergedFiles(myCommunity); + myMessageManager.nextAnswer(Messages.OK); + doMerge("feature"); + assertMessage(unmergedFilesErrorTitle("merge")); + assertBranch("master"); + assertEquals(tip(myUltimate), ultimateTip); + } + + @Test + public void deny_rollback_merge_should_leave_as_is() throws Exception { + prepareBranchWithCommit("feature"); + String ultimateTip = tip(myUltimate); + GitTestScenarioGenerator.prepareUnmergedFiles(myCommunity); + myMessageManager.nextAnswer(Messages.CANCEL); + doMerge("feature"); + assertMessage(unmergedFilesErrorTitle("merge")); + assertBranch("master"); + assertFalse(tip(myUltimate).equals(ultimateTip)); + } + + @Test + public void merge_with_untracked_files_overwritten_by_merge_in_first_repo_should_show_notification() throws Exception { + test_untracked_files_overwritten_by_in_first_repo(false); + } + + @Test + public void merge_with_untracked_files_overwritten_by_merge_in_second_repo_should_show_rollback_proposal_with_file_list() throws Exception { + test_checkout_with_untracked_files_overwritten_by_in_second_repo(false); + } + + @Test + public void merge_with_local_changes_overwritten_by_merge_should_show_smart_checkout_dialog() throws Exception { + test_operation_with_local_changes_overwritten_by_should_show_smart_checkout_dialog(false); + } + + @Test + public void agree_to_smart_merge_should_smart_merge() throws Exception { + prepare_agree_to_smart_operation(false); + for (GitRepository repository : myRepositories) { + assertEquals(read(repository, "local.txt"), "master\ninitial content\nfeature content\n"); + } + } + + @Test + public void deny_to_smart_merge_in_first_repo_should_show_notification() throws Exception { + test_deny_to_smart_operation_in_first_repo_should_show_notification(false); + } + + @Test + public void deny_to_smart_merge_in_second_repo_should_show_rollback_proposal() throws Exception { + test_deny_to_smart_operation_in_second_repo_should_show_rollback_proposal(false); + } + private void registerNotFullyMergedDialog(final int answer) { myDialogManager.registerDialogHandler(GitBranchIsNotFullyMergedDialog.class, new TestDialogHandler() { @Override @@ -475,9 +612,12 @@ public class GitBranchOperationsTest extends AbstractVcsTestCase { }); } - private static void prepareUnmergedBranch(GitRepository... repositories) throws IOException { + private void prepareBranchWithCommit(String branch, GitRepository... repositories) throws IOException { + if (repositories.length == 0) { + repositories = new GitRepository[] { myUltimate, myCommunity, myContrib }; + } for (GitRepository repository : repositories) { - checkout(repository, "-b", "unmerged_branch"); + checkout(repository, "-b", branch); createAddCommit(repository, "unmerged_branch_file"); checkout(repository, "master"); refresh(repository); @@ -488,6 +628,37 @@ public class GitBranchOperationsTest extends AbstractVcsTestCase { callPrivateBranchOperationsProcessorMethod("doCheckoutNewBranch", NEW_BRANCH); } + private void doMerge(String branch) throws InvocationTargetException, NoSuchMethodException, IllegalAccessException { + callPrivateBranchOperationsProcessorMethod("doMerge", branch); + // sync refresh is needed, because the refresh inside GitMergeOperation is asynchronous. + for (GitRepository repository : myRepositories) { + repository.getRoot().refresh(false, true); + } + } + + private void doCheckoutOrMerge(boolean checkout, String branch) throws InvocationTargetException, NoSuchMethodException, IllegalAccessException { + if (checkout) { + doCheckout(branch, null); + } + else { + doMerge(branch); + } + } + + private static String unmergedFilesErrorNotificationDescription(String operation) + throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { + Method method = GitBranchOperation.class.getDeclaredMethod("unmergedFilesErrorNotificationDescription", String.class); + method.setAccessible(true); + return (String) method.invoke(null, operation); + } + + private static String unmergedFilesErrorTitle(String operation) + throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { + Method method = GitBranchOperation.class.getDeclaredMethod("unmergedFilesErrorTitle", String.class); + method.setAccessible(true); + return (String) method.invoke(null, operation); + } + private void callPrivateBranchOperationsProcessorMethod(String methodName, String branchName) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException { // call private doCheckoutNewBranch instead of public checkoutNewBranch to avoid dealing with background process creation // same for other branch operations @@ -547,10 +718,10 @@ public class GitBranchOperationsTest extends AbstractVcsTestCase { TestMessageManager.Message message = myMessageManager.getLastMessage(); assertNotNull(message); if (title != null) { - assertEquals(message.getTitle(), title); + assertEquals(stripHtmlAndBreaks(message.getTitle()), stripHtmlAndBreaks(title)); } if (description != null) { - assertEquals(message.getDescription(), description); + assertEquals(stripHtmlAndBreaks(message.getDescription()), stripHtmlAndBreaks(description)); } if (yesButton != null) { assertEquals(message.getYesText(), yesButton); diff --git a/plugins/git4idea/tests/git4idea/test/GitExec.java b/plugins/git4idea/tests/git4idea/test/GitExec.java index e7c709f79fe2..6cabf8c9cb3d 100644 --- a/plugins/git4idea/tests/git4idea/test/GitExec.java +++ b/plugins/git4idea/tests/git4idea/test/GitExec.java @@ -121,6 +121,10 @@ public class GitExec { run(repository, "merge", branch); } + public static String tip(@NotNull GitRepository repository) throws IOException { + return run(repository, "rev-list", "-1", "HEAD"); + } + @NotNull private static String run(@NotNull GitRepository repository, @NotNull String command, String... params) throws IOException { return new GitTestRunEnv(new File(repository.getRoot().getPath())).run(command, params); diff --git a/plugins/git4idea/tests/git4idea/test/GitTestScenarioGenerator.java b/plugins/git4idea/tests/git4idea/test/GitTestScenarioGenerator.java index 021535ebad9e..10cb1fa5e5e2 100644 --- a/plugins/git4idea/tests/git4idea/test/GitTestScenarioGenerator.java +++ b/plugins/git4idea/tests/git4idea/test/GitTestScenarioGenerator.java @@ -27,13 +27,15 @@ import static git4idea.test.GitExec.*; */ public class GitTestScenarioGenerator { + private static final String BRANCH_FOR_UNMERGED_CONFLICTS = "unmerged_files_branch_" + Math.random(); + public static void prepareUnmergedFiles(@NotNull GitRepository... repositories) throws IOException { for (GitRepository repository : repositories) { String unmergedFile = "unmerged"; create(repository, unmergedFile, "master content"); addCommit(repository, unmergedFile); - checkoutFeatureBranch(repository); + checkoutBranchForUnmergedConflicts(repository); edit(repository, unmergedFile, "feature content"); addCommit(repository, unmergedFile); @@ -41,17 +43,17 @@ public class GitTestScenarioGenerator { edit(repository, unmergedFile, "master feature"); addCommit(repository, unmergedFile); - merge(repository, "feature"); + merge(repository, BRANCH_FOR_UNMERGED_CONFLICTS); refresh(repository); } } - private static void checkoutFeatureBranch(GitRepository repository) throws IOException { + private static void checkoutBranchForUnmergedConflicts(GitRepository repository) throws IOException { String branches = branch(repository); - if (!branches.contains("feature")) { - checkout(repository, "-b", "feature"); + if (!branches.contains(BRANCH_FOR_UNMERGED_CONFLICTS)) { + checkout(repository, "-b", BRANCH_FOR_UNMERGED_CONFLICTS); } else { - checkout(repository, "feature"); + checkout(repository, BRANCH_FOR_UNMERGED_CONFLICTS); } } }