Merge remote-tracking branch 'origin/master'
[idea/community.git] / plugins / git4idea / src / git4idea / branch / GitCheckoutOperation.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.NotificationType;
19 import com.intellij.openapi.diagnostic.Logger;
20 import com.intellij.openapi.progress.ProgressIndicator;
21 import com.intellij.openapi.project.Project;
22 import com.intellij.openapi.ui.VerticalFlowLayout;
23 import com.intellij.openapi.util.Clock;
24 import com.intellij.openapi.util.Condition;
25 import com.intellij.openapi.util.io.FileUtil;
26 import com.intellij.openapi.vcs.VcsException;
27 import com.intellij.openapi.vcs.changes.Change;
28 import com.intellij.openapi.vcs.changes.ChangeListManager;
29 import com.intellij.openapi.vcs.changes.ui.SelectFilesDialog;
30 import com.intellij.openapi.vcs.history.VcsRevisionNumber;
31 import com.intellij.openapi.vcs.merge.MergeDialogCustomizer;
32 import com.intellij.openapi.vfs.VirtualFile;
33 import com.intellij.ui.components.JBLabel;
34 import com.intellij.util.ArrayUtil;
35 import com.intellij.util.containers.ContainerUtil;
36 import com.intellij.util.continuation.ContinuationContext;
37 import com.intellij.util.text.DateFormatUtil;
38 import com.intellij.util.ui.UIUtil;
39 import com.intellij.vcsUtil.VcsUtil;
40 import git4idea.DialogManager;
41 import git4idea.GitUtil;
42 import git4idea.GitVcs;
43 import git4idea.commands.*;
44 import git4idea.merge.GitConflictResolver;
45 import git4idea.repo.GitRepository;
46 import git4idea.stash.GitChangesSaver;
47 import git4idea.update.GitComplexProcess;
48 import git4idea.util.GitUIUtil;
49 import git4idea.util.UntrackedFilesNotifier;
50 import org.jetbrains.annotations.NotNull;
51 import org.jetbrains.annotations.Nullable;
52
53 import javax.swing.*;
54 import java.util.*;
55 import java.util.concurrent.atomic.AtomicBoolean;
56
57 import static com.intellij.openapi.util.text.StringUtil.*;
58 import static git4idea.commands.GitMessageWithFilesDetector.Event.LOCAL_CHANGES_OVERWRITTEN_BY_CHECKOUT;
59 import static git4idea.commands.GitMessageWithFilesDetector.Event.UNTRACKED_FILES_OVERWRITTEN_BY;
60 import static git4idea.util.GitUIUtil.code;
61
62 /**
63  * Represents {@code git checkout} operation.
64  * Fails to checkout if there are unmerged files.
65  * Fails to checkout if there are untracked files that would be overwritten by checkout. Shows the list of files.
66  * If there are local changes that would be overwritten by checkout, proposes to perform a "smart checkout" which means stashing local
67  * changes, checking out, and then unstashing the changes back (possibly with showing the conflict resolving dialog). 
68  *
69  *  @author Kirill Likhodedov
70  */
71 public class GitCheckoutOperation extends GitBranchOperation {
72
73   private static final Logger LOG = Logger.getInstance(GitCheckoutOperation.class);
74
75   @NotNull private final String myStartPointReference;
76   @Nullable private final String myNewBranch;
77   @NotNull private final String myPreviousBranch;
78
79   public GitCheckoutOperation(@NotNull Project project, @NotNull Collection<GitRepository> repositories,
80                               @NotNull String startPointReference, @Nullable String newBranch, @NotNull String previousBranch, 
81                               @NotNull ProgressIndicator indicator) {
82     super(project, repositories, indicator);
83     myStartPointReference = startPointReference;
84     myNewBranch = newBranch;
85     myPreviousBranch = previousBranch;
86   }
87   
88   @Override
89   protected void execute() {
90     boolean fatalErrorHappened = false;
91     while (hasMoreRepositories() && !fatalErrorHappened) {
92       final GitRepository repository = next();
93
94       VirtualFile root = repository.getRoot();
95       GitMessageWithFilesDetector localChangesOverwrittenByCheckout = new GitMessageWithFilesDetector(LOCAL_CHANGES_OVERWRITTEN_BY_CHECKOUT, root);
96       GitSimpleEventDetector unmergedFiles = new GitSimpleEventDetector(GitSimpleEventDetector.Event.UNMERGED);
97       GitMessageWithFilesDetector untrackedOverwrittenByCheckout = new GitMessageWithFilesDetector(UNTRACKED_FILES_OVERWRITTEN_BY, root);
98
99       GitCommandResult result = Git.checkout(repository, myStartPointReference, myNewBranch,
100                                              localChangesOverwrittenByCheckout, unmergedFiles, untrackedOverwrittenByCheckout);
101       if (result.success()) {
102         refresh(repository);
103         markSuccessful(repository);
104       }
105       else if (unmergedFiles.hasHappened()) {
106         fatalUnmergedFilesError();
107         fatalErrorHappened = true;
108       }
109       else if (localChangesOverwrittenByCheckout.wasMessageDetected()) {
110         boolean smartCheckoutSucceeded = smartCheckoutOrNotify(repository, localChangesOverwrittenByCheckout);
111         if (!smartCheckoutSucceeded) {
112           fatalErrorHappened = true;
113         }
114       }
115       else if (untrackedOverwrittenByCheckout.wasMessageDetected()) {
116         fatalUntrackedFilesError(untrackedOverwrittenByCheckout.getFiles());
117         fatalErrorHappened = true;
118       }
119       else {
120         fatalError(getCommonErrorTitle(), result.getErrorOutputAsJoinedString());
121         fatalErrorHappened = true;
122       }
123     }
124
125     if (!fatalErrorHappened) {
126       notifySuccess();
127     }
128   }
129
130   private boolean smartCheckoutOrNotify(@NotNull GitRepository repository, 
131                                         @NotNull GitMessageWithFilesDetector localChangesOverwrittenByCheckout) {
132     // get changes overwritten by checkout from the error message captured from Git
133     List<Change> affectedChanges = getChangesAffectedByCheckout(repository, localChangesOverwrittenByCheckout.getRelativeFilePaths(), true);
134     // get all other conflicting changes
135     Map<GitRepository, List<Change>> conflictingChangesInRepositories = collectLocalChangesOnAllOtherRepositories(repository);
136     Set<GitRepository> otherProblematicRepositories = conflictingChangesInRepositories.keySet();
137     Collection<GitRepository> allConflictingRepositories = new ArrayList<GitRepository>(otherProblematicRepositories);
138     allConflictingRepositories.add(repository);
139     for (List<Change> changes : conflictingChangesInRepositories.values()) {
140       affectedChanges.addAll(changes);
141     }
142
143     if (GitWouldBeOverwrittenByCheckoutDialog.showAndGetAnswer(myProject, affectedChanges)) {
144       boolean smartCheckedOutSuccessfully = smartCheckout(allConflictingRepositories, myStartPointReference, myNewBranch, getIndicator());
145       if (smartCheckedOutSuccessfully) {
146         GitRepository[] otherRepositories = ArrayUtil.toObjectArray(otherProblematicRepositories, GitRepository.class);
147
148         markSuccessful(repository);
149         markSuccessful(otherRepositories);
150         refresh(repository);
151         refresh(otherRepositories);
152         return true;
153       }
154       else {
155         // notification is handled in smartCheckout()
156         return false;
157       }
158     }
159     else {
160       fatalLocalChangesError();
161       return false;
162     }
163   }
164
165   private void fatalLocalChangesError() {
166     String title = "Couldn't checkout " + myStartPointReference;
167     String message = "Local changes would be overwritten by checkout.<br/>Stash or commit them before checking out a branch.<br/>";
168     if (wereSuccessful()) {
169       showFatalErrorDialogWithRollback(title, message);
170     }
171     else {
172       showFatalNotification(title, message);
173     }
174   }
175
176   @NotNull
177   private Map<GitRepository, List<Change>> collectLocalChangesOnAllOtherRepositories(@NotNull final GitRepository currentRepository) {
178     // get changes in all other repositories (except those which already have succeeded) to avoid multiple dialogs proposing smart checkout
179     List<GitRepository> remainingRepositories = ContainerUtil.filter(getRepositories(), new Condition<GitRepository>() {
180       @Override
181       public boolean value(GitRepository repo) {
182         return !repo.equals(currentRepository) && !getSuccessfulRepositories().contains(repo);
183       }
184     });
185     return collectChangesConflictingWithCheckout(remainingRepositories);
186   }
187
188   private void fatalUntrackedFilesError(@NotNull Collection<VirtualFile> untrackedFiles) {
189     if (wereSuccessful()) {
190       showUntrackedFilesDialogWithRollback(untrackedFiles);
191     }
192     else {
193       showUntrackedFilesNotification(untrackedFiles);
194     }
195   }
196
197   private void showUntrackedFilesNotification(@NotNull Collection<VirtualFile> untrackedFiles) {
198     UntrackedFilesNotifier.notifyUntrackedFilesOverwrittenBy(myProject, untrackedFiles, "checkout");
199   }
200
201   private void showUntrackedFilesDialogWithRollback(@NotNull Collection<VirtualFile> untrackedFiles) {
202     String title = "Couldn't checkout";
203     String description = UntrackedFilesNotifier.createUntrackedFilesOverwrittenDescription("checkout", true);
204
205     final SelectFilesDialog dialog = new UntrackedFilesDialog(myProject, new ArrayList<VirtualFile>(untrackedFiles),
206                                                               stripHtml(description, true));
207     dialog.setTitle(title);
208     UIUtil.invokeAndWaitIfNeeded(new Runnable() {
209       @Override
210       public void run() {
211         DialogManager.getInstance(myProject).showDialog(dialog);
212       }
213     });
214
215     if (dialog.isOK()) {
216       rollback();
217     }
218   }
219
220   private class UntrackedFilesDialog extends SelectFilesDialog {
221
222     public UntrackedFilesDialog(@NotNull Project project, @NotNull List<VirtualFile> originalFiles, @NotNull String prompt) {
223       super(project, originalFiles, prompt, null, false, false);
224       setOKButtonText("Rollback");
225       setCancelButtonText("Don't rollback");
226     }
227
228     @Override
229     protected JComponent createSouthPanel() {
230       JComponent buttons = super.createSouthPanel();
231       JPanel panel = new JPanel(new VerticalFlowLayout());
232       panel.add(new JBLabel("<html>" + getRollbackProposal() + "</html>"));
233       panel.add(buttons);
234       return panel;
235     }
236   }
237
238   @NotNull
239   @Override
240   protected String getRollbackProposal() {
241     return "However checkout has succeeded for the following " + repositories() + ":<br/>" +
242            successfulRepositoriesJoined() +
243            "<br/>You may rollback (checkout back to " + myPreviousBranch + ") not to let branches diverge.";
244   }
245
246   @Override
247   protected void rollback() {
248     GitCompoundResult checkoutResult = new GitCompoundResult(myProject);
249     GitCompoundResult deleteResult = new GitCompoundResult(myProject);
250     for (GitRepository repository : getSuccessfulRepositories()) {
251       GitCommandResult result = Git.checkout(repository, myPreviousBranch, null);
252       checkoutResult.append(repository, result);
253       if (result.success() && myNewBranch != null) {
254         /*
255           force delete is needed, because we create new branch from branch other that the current one
256           e.g. being on master create newBranch from feature,
257           then rollback => newBranch is not fully merged to master (although it is obviously fully merged to feature).
258          */
259         deleteResult.append(repository, Git.branchDelete(repository, myNewBranch, true));
260       }
261       refresh(repository);
262     }
263     if (!checkoutResult.totalSuccess() || !deleteResult.totalSuccess()) {
264       StringBuilder message = new StringBuilder();
265       if (!checkoutResult.totalSuccess()) {
266         message.append("Errors during checking out ").append(myPreviousBranch).append(": ");
267         message.append(checkoutResult.getErrorOutputWithReposIndication());
268       }
269       if (!deleteResult.totalSuccess()) {
270         message.append("Errors during deleting ").append(code(myNewBranch)).append(": ");
271         message.append(deleteResult.getErrorOutputWithReposIndication());
272       }
273       GitUIUtil.notify(GitVcs.IMPORTANT_ERROR_NOTIFICATION, myProject, "Error during rollback",
274                        message.toString(), NotificationType.ERROR, null);
275     }
276   }
277
278   @NotNull
279   private String getCommonErrorTitle() {
280     return "Couldn't checkout " + myStartPointReference;
281   }
282
283   @NotNull
284   private Map<GitRepository, List<Change>> collectChangesConflictingWithCheckout(@NotNull Collection<GitRepository> repositories) {
285     Map<GitRepository, List<Change>> changes = new HashMap<GitRepository, List<Change>>();
286     for (GitRepository repository : repositories) {
287       try {
288         Collection<String> diff = GitUtil.getPathsDiffBetweenRefs(myPreviousBranch, myStartPointReference, myProject, repository.getRoot());
289         List<Change> changesInRepo = getChangesAffectedByCheckout(repository, diff, false);
290         if (!changesInRepo.isEmpty()) {
291           changes.put(repository, changesInRepo);
292         }
293       }
294       catch (VcsException e) {
295         // ignoring the exception: this is not fatal if we won't collect such a diff from other repositories. 
296         // At worst, use will get double dialog proposing the smart checkout.
297         LOG.warn(String.format("Couldn't collect diff between %s and %s in %s", myPreviousBranch, myStartPointReference, repository.getRoot()), e);
298       }
299     }
300     return changes;
301   }
302
303   @NotNull
304   @Override
305   public String getSuccessMessage() {
306     if (myNewBranch == null) {
307       return String.format("Checked out <b><code>%s</code></b>", myStartPointReference);
308     }
309     return String.format("Checked out new branch <b><code>%s</code></b> from <b><code>%s</code></b>", myNewBranch, myStartPointReference);
310   }
311
312   // stash - checkout - unstash
313   private boolean smartCheckout(@NotNull final Collection<GitRepository> repositories, @NotNull final String reference, @Nullable final String newBranch, @NotNull ProgressIndicator indicator) {
314     final GitChangesSaver saver = configureSaver(reference, indicator);
315
316     final AtomicBoolean result = new AtomicBoolean();
317     GitComplexProcess.Operation checkoutOperation = new GitComplexProcess.Operation() {
318       @Override public void run(ContinuationContext context) {
319         boolean savedSuccessfully = save(repositories, saver);
320         if (savedSuccessfully) {
321           try {
322             result.set(checkoutOrNotify(repositories, reference, newBranch));
323           } finally {
324             saver.restoreLocalChanges(context);
325           }
326         }
327       }
328     };
329     GitComplexProcess.execute(myProject, "checkout", checkoutOperation);
330     return result.get();
331   }
332
333   /**
334    * Configures the saver, actually notifications and texts in the GitConflictResolver used inside.
335    */
336   private GitChangesSaver configureSaver(final String reference, ProgressIndicator indicator) {
337     GitChangesSaver saver = GitChangesSaver.getSaver(myProject, indicator, String.format("Checkout %s at %s",
338                                                                                          reference,
339                                                                                          DateFormatUtil.formatDateTime(Clock.getTime())));
340     MergeDialogCustomizer mergeDialogCustomizer = new MergeDialogCustomizer() {
341       @Override
342       public String getMultipleFileMergeDescription(Collection<VirtualFile> files) {
343         return String.format(
344           "<html>Uncommitted changes that were saved before checkout have conflicts with files from <code>%s</code></html>",
345           reference);
346       }
347
348       @Override
349       public String getLeftPanelTitle(VirtualFile file) {
350         return "Uncommitted changes";
351       }
352
353       @Override
354       public String getRightPanelTitle(VirtualFile file, VcsRevisionNumber lastRevisionNumber) {
355         return String.format("<html>Changes from <b><code>%s</code></b></html>", reference);
356       }
357     };
358
359     GitConflictResolver.Params params = new GitConflictResolver.Params().
360       setReverse(true).
361       setMergeDialogCustomizer(mergeDialogCustomizer).
362       setErrorNotificationTitle("Local changes were not restored");
363
364     saver.setConflictResolverParams(params);
365     return saver;
366   }
367
368   /**
369    * Saves local changes. In case of error shows a notification and returns false.
370    */
371   private boolean save(@NotNull Collection<GitRepository> repositories, @NotNull GitChangesSaver saver) {
372     try {
373       saver.saveLocalChanges(GitUtil.getRoots(repositories));
374       return true;
375     } catch (VcsException e) {
376       LOG.info("Couldn't save local changes", e);
377       notifyError("Couldn't save uncommitted changes.",
378                   String.format("Tried to save uncommitted changes in %s before checkout, but failed with an error.<br/>%s",
379                                 saver.getSaverName(), join(e.getMessages())));
380       return false;
381     }
382   }
383
384   /**
385    * Checks out or shows an error message.
386    */
387   private boolean checkoutOrNotify(@NotNull Collection<GitRepository> repositories,
388                                                     @NotNull String reference,
389                                                     @Nullable String newBranch) {
390     GitCompoundResult compoundResult = new GitCompoundResult(myProject);
391     for (GitRepository repository : repositories) {
392       compoundResult.append(repository, Git.checkout(repository, reference, newBranch));
393     }
394     if (compoundResult.totalSuccess()) {
395       return true;
396     }
397     notifyError("Couldn't checkout " + reference, compoundResult.getErrorOutputWithReposIndication());
398     return false;
399   }
400
401   /**
402    * Forms the list of the changes, that would be overwritten by checkout.
403    *
404    * @param repository
405    * @param affectedPaths paths returned by Git.
406    * @param relativePaths Are the paths specified relative or absolute.
407    * @return List of Changes is these paths.
408    */
409   private List<Change> getChangesAffectedByCheckout(@NotNull GitRepository repository, @NotNull Collection<String> affectedPaths, boolean relativePaths) {
410     ChangeListManager changeListManager = ChangeListManager.getInstance(myProject);
411     List<Change> affectedChanges = new ArrayList<Change>();
412     for (String path : affectedPaths) {
413       VirtualFile file;
414       if (relativePaths) {
415         file = repository.getRoot().findFileByRelativePath(FileUtil.toSystemIndependentName(path));
416       }
417       else {
418         file = VcsUtil.getVirtualFile(path);
419       }
420
421       if (file != null) {
422         Change change = changeListManager.getChange(file);
423         if (change != null) {
424           affectedChanges.add(change);
425         }
426       }
427     }
428     return affectedChanges;
429   }
430
431   private static void refresh(GitRepository... repositories) {
432     for (GitRepository repository : repositories) {
433       refreshRoot(repository);
434       // repository state will be auto-updated with this VFS refresh => no need to call GitRepository#update().
435     }
436   }
437   
438   private static void refreshRoot(GitRepository repository) {
439     repository.getRoot().refresh(true, true);
440   }
441   
442 }