IDEA-81134 'Untracked Files Preventing Checkout': add action to delete files
[idea/community.git] / plugins / git4idea / src / git4idea / branch / GitBranchOperation.java
1 /*
2  * Copyright 2000-2012 JetBrains s.r.o.
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  * http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package git4idea.branch;
17
18 import com.intellij.notification.Notification;
19 import com.intellij.notification.NotificationListener;
20 import com.intellij.notification.NotificationType;
21 import com.intellij.openapi.components.ServiceManager;
22 import com.intellij.openapi.diagnostic.Logger;
23 import com.intellij.openapi.fileEditor.FileDocumentManager;
24 import com.intellij.openapi.progress.ProgressIndicator;
25 import com.intellij.openapi.project.Project;
26 import com.intellij.openapi.ui.Messages;
27 import com.intellij.openapi.ui.VerticalFlowLayout;
28 import com.intellij.openapi.util.Pair;
29 import com.intellij.openapi.util.text.StringUtil;
30 import com.intellij.openapi.vcs.VcsException;
31 import com.intellij.openapi.vcs.changes.Change;
32 import com.intellij.openapi.vcs.changes.ui.SelectFilesDialog;
33 import com.intellij.openapi.vfs.VirtualFile;
34 import com.intellij.ui.components.JBLabel;
35 import com.intellij.util.Function;
36 import com.intellij.util.ui.UIUtil;
37 import git4idea.*;
38 import git4idea.commands.Git;
39 import git4idea.commands.GitMessageWithFilesDetector;
40 import git4idea.config.GitVcsSettings;
41 import git4idea.merge.GitConflictResolver;
42 import git4idea.repo.GitRepository;
43 import git4idea.util.UntrackedFilesNotifier;
44 import org.jetbrains.annotations.NotNull;
45
46 import javax.swing.*;
47 import javax.swing.event.HyperlinkEvent;
48 import java.util.*;
49 import java.util.concurrent.atomic.AtomicBoolean;
50
51 import static com.intellij.openapi.util.text.StringUtil.pluralize;
52 import static com.intellij.openapi.util.text.StringUtil.stripHtml;
53
54 /**
55  * Common class for Git operations with branches aware of multi-root configuration,
56  * which means showing combined error information, proposing to rollback, etc.
57  *
58  * @author Kirill Likhodedov
59  */
60 abstract class GitBranchOperation {
61
62   private static final Logger LOG = Logger.getInstance(GitBranchOperation.class);
63
64   @NotNull protected final Project myProject;
65   @NotNull protected final Git myGit;
66   @NotNull private final Collection<GitRepository> myRepositories;
67   @NotNull private final String myCurrentBranchOrRev;
68   @NotNull private final ProgressIndicator myIndicator;
69   private final GitVcsSettings mySettings;
70
71   @NotNull private final Collection<GitRepository> mySuccessfulRepositories;
72   @NotNull private final Collection<GitRepository> myRemainingRepositories;
73
74   protected GitBranchOperation(@NotNull Project project, @NotNull Git git, @NotNull Collection<GitRepository> repositories,
75                                @NotNull String currentBranchOrRev, @NotNull ProgressIndicator indicator) {
76     myProject = project;
77     myGit = git;
78     myRepositories = repositories;
79     myCurrentBranchOrRev = currentBranchOrRev;
80     myIndicator = indicator;
81     mySuccessfulRepositories = new ArrayList<GitRepository>();
82     myRemainingRepositories = new ArrayList<GitRepository>(myRepositories);
83     mySettings = GitVcsSettings.getInstance(myProject);
84   }
85
86   protected abstract void execute();
87
88   protected abstract void rollback();
89
90   @NotNull
91   public abstract String getSuccessMessage();
92
93   @NotNull
94   protected abstract String getRollbackProposal();
95
96   /**
97    * Returns a short downcased name of the operation.
98    * It is used by some dialogs or notifications which are common to several operations.
99    * Some operations (like checkout new branch) can be not mentioned in these dialogs, so their operation names would be not used.
100    */
101   @NotNull
102   protected abstract String getOperationName();
103
104   /**
105    * @return next repository that wasn't handled (e.g. checked out) yet.
106    */
107   @NotNull
108   protected GitRepository next() {
109     return myRemainingRepositories.iterator().next();
110   }
111
112   /**
113    * @return true if there are more repositories on which the operation wasn't executed yet.
114    */
115   protected boolean hasMoreRepositories() {
116     return !myRemainingRepositories.isEmpty();
117   }
118
119   /**
120    * Marks repositories as successful, i.e. they won't be handled again.
121    */
122   protected void markSuccessful(GitRepository... repositories) {
123     for (GitRepository repository : repositories) {
124       mySuccessfulRepositories.add(repository);
125       myRemainingRepositories.remove(repository);
126     }
127   }
128
129   /**
130    * @return true if the operation has already succeeded in at least one of repositories.
131    */
132   protected boolean wereSuccessful() {
133     return !mySuccessfulRepositories.isEmpty();
134   }
135   
136   @NotNull
137   protected Collection<GitRepository> getSuccessfulRepositories() {
138     return mySuccessfulRepositories;
139   }
140   
141   @NotNull
142   protected String successfulRepositoriesJoined() {
143     return StringUtil.join(mySuccessfulRepositories, new Function<GitRepository, String>() {
144       @Override
145       public String fun(GitRepository repository) {
146         return repository.getPresentableUrl();
147       }
148     }, "<br/>");
149   }
150   
151   @NotNull
152   protected Collection<GitRepository> getRepositories() {
153     return myRepositories;
154   }
155
156   @NotNull
157   protected Collection<GitRepository> getRemainingRepositories() {
158     return myRemainingRepositories;
159   }
160
161   @NotNull
162   protected List<GitRepository> getRemainingRepositoriesExceptGiven(@NotNull final GitRepository currentRepository) {
163     List<GitRepository> repositories = new ArrayList<GitRepository>(myRemainingRepositories);
164     repositories.remove(currentRepository);
165     return repositories;
166   }
167
168   protected void notifySuccess(@NotNull String message) {
169     Notificator.getInstance(myProject).notify(GitVcs.NOTIFICATION_GROUP_ID, "", message, NotificationType.INFORMATION);
170   }
171
172   protected final void notifySuccess() {
173     notifySuccess(getSuccessMessage());
174   }
175
176   protected static void saveAllDocuments() {
177     UIUtil.invokeAndWaitIfNeeded(new Runnable() {
178       @Override
179       public void run() {
180         FileDocumentManager.getInstance().saveAllDocuments();
181       }
182     });
183   }
184
185   /**
186    * Show fatal error as a notification or as a dialog with rollback proposal.
187    */
188   protected void fatalError(@NotNull String title, @NotNull String message) {
189     if (wereSuccessful())  {
190       showFatalErrorDialogWithRollback(title, message);
191     }
192     else {
193       showFatalNotification(title, message);
194     }
195   }
196
197   protected void showFatalErrorDialogWithRollback(@NotNull final String title, @NotNull final String message) {
198     final AtomicBoolean ok = new AtomicBoolean();
199     UIUtil.invokeAndWaitIfNeeded(new Runnable() {
200       @Override
201       public void run() {
202         StringBuilder description = new StringBuilder("<html>");
203         if (!StringUtil.isEmptyOrSpaces(message)) {
204           description.append(message).append("<br/>");
205         }
206         description.append(getRollbackProposal()).append("</html>");
207         ok.set(Messages.OK ==
208                MessageManager.showYesNoDialog(myProject, description.toString(), title, "Rollback", "Don't rollback", Messages.getErrorIcon()));
209       }
210     });
211     if (ok.get()) {
212       rollback();
213     }
214   }
215
216   @NotNull
217   private String unmergedFilesErrorTitle() {
218     return unmergedFilesErrorTitle(getOperationName());
219   }
220
221   @NotNull
222   private static String unmergedFilesErrorTitle(String operationName) {
223     return "Can't " + operationName + " because of unmerged files";
224   }
225
226   @NotNull
227   private String unmergedFilesErrorNotificationDescription() {
228     return unmergedFilesErrorNotificationDescription(getOperationName());
229   }
230
231   @NotNull
232   private static String unmergedFilesErrorNotificationDescription(String operationName) {
233     return "You have to <a href='resolve'>resolve</a> all merge conflicts before " + operationName + ".<br/>" +
234     "After resolving conflicts you also probably would want to commit your files to the current branch.";
235   }
236
237   protected void showFatalNotification(@NotNull String title, @NotNull String message) {
238     notifyError(title, message);
239   }
240
241   protected void notifyError(@NotNull String title, @NotNull String message) {
242     Notificator.getInstance(myProject).notify(GitVcs.IMPORTANT_ERROR_NOTIFICATION, title, message, NotificationType.ERROR);
243   }
244
245   @NotNull
246   protected ProgressIndicator getIndicator() {
247     return myIndicator;
248   }
249
250   /**
251    * Display the error saying that the operation can't be performed because there are unmerged files in a repository.
252    * Such error prevents checking out and creating new branch.
253    */
254   protected void fatalUnmergedFilesError() {
255     if (wereSuccessful()) {
256       showUnmergedFilesDialogWithRollback();
257     }
258     else {
259       showUnmergedFilesNotification();
260     }
261   }
262
263   @NotNull
264   protected String repositories() {
265     return pluralize("repository", getSuccessfulRepositories().size());
266   }
267
268   /**
269    * Updates the recently visited branch in the settings.
270    * This is to be performed after successful checkout operation.
271    */
272   protected void updateRecentBranch() {
273     if (getRepositories().size() == 1) {
274       GitRepository repository = myRepositories.iterator().next();
275       mySettings.setRecentBranchOfRepository(repository.getRoot().getPath(), myCurrentBranchOrRev);
276     }
277     else {
278       mySettings.setRecentCommonBranch(myCurrentBranchOrRev);
279     }
280   }
281
282   private void showUnmergedFilesDialogWithRollback() {
283     final AtomicBoolean ok = new AtomicBoolean();
284     UIUtil.invokeAndWaitIfNeeded(new Runnable() {
285       @Override public void run() {
286         String description = "<html>You have to resolve all merge conflicts before " + getOperationName() + ".<br/>" +
287                              getRollbackProposal() + "</html>";
288         // suppressing: this message looks ugly if capitalized by words
289         //noinspection DialogTitleCapitalization
290         ok.set(Messages.OK == MessageManager.showYesNoDialog(myProject, description, unmergedFilesErrorTitle(),
291                                                              "Rollback", "Don't rollback", Messages.getErrorIcon()));
292       }
293     });
294     if (ok.get()) {
295       rollback();
296     }
297   }
298
299   private void showUnmergedFilesNotification() {
300     String title = unmergedFilesErrorTitle();
301     String description = unmergedFilesErrorNotificationDescription();
302     Notificator.getInstance(myProject).notify(GitVcs.IMPORTANT_ERROR_NOTIFICATION, title, description, NotificationType.ERROR,
303                                                       new NotificationListener() {
304       @Override public void hyperlinkUpdate(@NotNull Notification notification, @NotNull HyperlinkEvent event) {
305         if (event.getEventType() == HyperlinkEvent.EventType.ACTIVATED && event.getDescription().equals("resolve")) {
306           GitConflictResolver.Params params = new GitConflictResolver.Params().
307                 setMergeDescription("The following files have unresolved conflicts. You need to resolve them before " +
308                                     getOperationName() + ".").
309                 setErrorNotificationTitle("Unresolved files remain.");
310           new GitConflictResolver(myProject, myGit, ServiceManager.getService(PlatformFacade.class), GitUtil.getRootsFromRepositories(
311             getRepositories()), params).merge();
312         }
313       }
314     });
315   }
316
317   /**
318    * Asynchronously refreshes the VFS root directory of the given repository.
319    */
320   protected static void refreshRoot(@NotNull GitRepository repository) {
321     repository.getRoot().refresh(true, true);
322   }
323
324   protected void fatalLocalChangesError(@NotNull String reference) {
325     String title = String.format("Couldn't %s %s", getOperationName(), reference);
326     if (wereSuccessful()) {
327       showFatalErrorDialogWithRollback(title, "");
328     }
329   }
330
331   /**
332    * Shows the error "The following untracked working tree files would be overwritten by checkout/merge".
333    * If there were no repositories that succeeded the operation, shows a notification with a link to the list of these untracked files.
334    * If some repositories succeeded, shows a dialog with the list of these files and a proposal to rollback the operation of those
335    * repositories.
336    */
337   protected void fatalUntrackedFilesError(@NotNull Collection<VirtualFile> untrackedFiles) {
338     if (wereSuccessful()) {
339       showUntrackedFilesDialogWithRollback(untrackedFiles);
340     }
341     else {
342       showUntrackedFilesNotification(untrackedFiles);
343     }
344   }
345
346   private void showUntrackedFilesNotification(@NotNull Collection<VirtualFile> untrackedFiles) {
347     UntrackedFilesNotifier.notifyUntrackedFilesOverwrittenBy(myProject, ServiceManager.getService(myProject, PlatformFacade.class),
348                                                              untrackedFiles, getOperationName(), null);
349   }
350
351   private void showUntrackedFilesDialogWithRollback(@NotNull Collection<VirtualFile> untrackedFiles) {
352     String title = "Couldn't " + getOperationName();
353     String description = UntrackedFilesNotifier.createUntrackedFilesOverwrittenDescription(getOperationName(), false);
354
355     final SelectFilesDialog dialog = new UntrackedFilesDialog(myProject, new ArrayList<VirtualFile>(untrackedFiles),
356                                                               stripHtml(description, true));
357     dialog.setTitle(title);
358     UIUtil.invokeAndWaitIfNeeded(new Runnable() {
359       @Override
360       public void run() {
361         ServiceManager.getService(myProject, PlatformFacade.class).showDialog(dialog);
362       }
363     });
364
365     if (dialog.isOK()) {
366       rollback();
367     }
368   }
369
370   /**
371    * TODO this is non-optimal and even incorrect, since such diff shows the difference between committed changes
372    * For each of the given repositories looks to the diff between current branch and the given branch and converts it to the list of
373    * local changes.
374    */
375   @NotNull
376   static Map<GitRepository, List<Change>> collectLocalChangesConflictingWithBranch(@NotNull Project project,
377                                                                                    @NotNull Collection<GitRepository> repositories,
378                                                                                    @NotNull String currentBranch,
379                                                                                    @NotNull String otherBranch) {
380     Map<GitRepository, List<Change>> changes = new HashMap<GitRepository, List<Change>>();
381     for (GitRepository repository : repositories) {
382       try {
383         Collection<String> diff = GitUtil.getPathsDiffBetweenRefs(currentBranch, otherBranch, project, repository.getRoot());
384         List<Change> changesInRepo = GitUtil.convertPathsToChanges(repository, diff, false);
385         if (!changesInRepo.isEmpty()) {
386           changes.put(repository, changesInRepo);
387         }
388       }
389       catch (VcsException e) {
390         // ignoring the exception: this is not fatal if we won't collect such a diff from other repositories.
391         // At worst, use will get double dialog proposing the smart checkout.
392         LOG.warn(String.format("Couldn't collect diff between %s and %s in %s", currentBranch, otherBranch, repository.getRoot()), e);
393       }
394     }
395     return changes;
396   }
397
398   private class UntrackedFilesDialog extends SelectFilesDialog {
399
400     public UntrackedFilesDialog(@NotNull Project project, @NotNull List<VirtualFile> originalFiles, @NotNull String prompt) {
401       super(project, originalFiles, prompt, null, false, false, false);
402       setOKButtonText("Rollback");
403       setCancelButtonText("Don't rollback");
404     }
405
406     @Override
407     protected JComponent createSouthPanel() {
408       JComponent buttons = super.createSouthPanel();
409       JPanel panel = new JPanel(new VerticalFlowLayout());
410       panel.add(new JBLabel("<html>" + getRollbackProposal() + "</html>"));
411       panel.add(buttons);
412       return panel;
413     }
414   }
415
416   /**
417    * When checkout or merge operation on a repository fails with the error "local changes would be overwritten by...",
418    * affected local files are captured by the {@link git4idea.commands.GitMessageWithFilesDetector detector}.
419    * Then all remaining (non successful repositories) are searched if they are about to fail with the same problem.
420    * All collected local changes which prevent the operation, together with these repositories, are returned.
421    * @param currentRepository          The first repository which failed the operation.
422    * @param localChangesOverwrittenBy  The detector of local changes would be overwritten by merge/checkout.
423    * @param currentBranch              Current branch.
424    * @param nextBranch                 Branch to compare with (the branch to be checked out, or the branch to be merged).
425    * @return Repositories that have failed or would fail with the "local changes" error, together with these local changes.
426    */
427   @NotNull
428   protected Pair<List<GitRepository>, List<Change>> getConflictingRepositoriesAndAffectedChanges(
429     @NotNull GitRepository currentRepository, @NotNull GitMessageWithFilesDetector localChangesOverwrittenBy,
430     String currentBranch, String nextBranch) {
431
432     // get changes overwritten by checkout from the error message captured from Git
433     List<Change> affectedChanges = GitUtil.convertPathsToChanges(currentRepository, localChangesOverwrittenBy.getRelativeFilePaths(), true);
434     // get all other conflicting changes
435     // get changes in all other repositories (except those which already have succeeded) to avoid multiple dialogs proposing smart checkout
436     Map<GitRepository, List<Change>> conflictingChangesInRepositories =
437       collectLocalChangesConflictingWithBranch(myProject, getRemainingRepositoriesExceptGiven(currentRepository), currentBranch, nextBranch);
438
439     Set<GitRepository> otherProblematicRepositories = conflictingChangesInRepositories.keySet();
440     List<GitRepository> allConflictingRepositories = new ArrayList<GitRepository>(otherProblematicRepositories);
441     allConflictingRepositories.add(currentRepository);
442     for (List<Change> changes : conflictingChangesInRepositories.values()) {
443       affectedChanges.addAll(changes);
444     }
445
446     return Pair.create(allConflictingRepositories, affectedChanges);
447   }
448
449 }