[git] Fix show diff with parent for merge revisions, especially for moved files ...
[idea/community.git] / plugins / git4idea / src / git4idea / history / GitDiffFromHistoryHandler.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.history;
17
18 import com.intellij.openapi.actionSystem.*;
19 import com.intellij.openapi.actionSystem.impl.SimpleDataContext;
20 import com.intellij.openapi.components.ServiceManager;
21 import com.intellij.openapi.diagnostic.Logger;
22 import com.intellij.openapi.progress.ProgressIndicator;
23 import com.intellij.openapi.progress.Task;
24 import com.intellij.openapi.project.Project;
25 import com.intellij.openapi.ui.DialogBuilder;
26 import com.intellij.openapi.ui.MessageType;
27 import com.intellij.openapi.ui.popup.JBPopupFactory;
28 import com.intellij.openapi.ui.popup.ListPopup;
29 import com.intellij.openapi.util.Pair;
30 import com.intellij.openapi.util.io.FileUtil;
31 import com.intellij.openapi.vcs.FilePath;
32 import com.intellij.openapi.vcs.VcsException;
33 import com.intellij.openapi.vcs.changes.Change;
34 import com.intellij.openapi.vcs.changes.ContentRevision;
35 import com.intellij.openapi.vcs.changes.ui.ChangesBrowser;
36 import com.intellij.openapi.vcs.history.CurrentRevision;
37 import com.intellij.openapi.vcs.history.DiffFromHistoryHandler;
38 import com.intellij.openapi.vcs.history.VcsFileRevision;
39 import com.intellij.openapi.vcs.history.VcsHistoryUtil;
40 import com.intellij.openapi.vcs.ui.VcsBalloonProblemNotifier;
41 import com.intellij.ui.awt.RelativePoint;
42 import com.intellij.util.ArrayUtil;
43 import com.intellij.util.Consumer;
44 import git4idea.GitFileRevision;
45 import git4idea.GitRevisionNumber;
46 import git4idea.GitUtil;
47 import git4idea.changes.GitChangeUtils;
48 import git4idea.commands.Git;
49 import git4idea.commands.GitCommandResult;
50 import git4idea.repo.GitRepository;
51 import git4idea.repo.GitRepositoryManager;
52 import org.jetbrains.annotations.NotNull;
53 import org.jetbrains.annotations.Nullable;
54
55 import java.awt.event.MouseEvent;
56 import java.util.ArrayList;
57 import java.util.Collection;
58 import java.util.Collections;
59 import java.util.List;
60
61 /**
62  * {@link DiffFromHistoryHandler#showDiffForTwo(FilePath, VcsFileRevision, VcsFileRevision) "Show Diff" for 2 revision} calls the common code.
63  * {@link DiffFromHistoryHandler#showDiffForOne(com.intellij.openapi.actionSystem.AnActionEvent, com.intellij.openapi.vcs.FilePath, com.intellij.openapi.vcs.history.VcsFileRevision, com.intellij.openapi.vcs.history.VcsFileRevision) "Show diff" for 1 revision}
64  * behaves differently for merge commits: for them it shown a popup displaying the parents of the selected commit. Selecting a parent
65  * from the popup shows the difference with this parent.
66  * If an ordinary (not merge) revision with 1 parent, it is the same as usual: just compare with the parent;
67  *
68  * @author Kirill Likhodedov
69  */
70 public class GitDiffFromHistoryHandler implements DiffFromHistoryHandler {
71   
72   private static final Logger LOG = Logger.getInstance(GitDiffFromHistoryHandler.class);
73
74   @NotNull private final Project myProject;
75   @NotNull private final Git myGit;
76   @NotNull private final GitRepositoryManager myRepositoryManager;
77
78   public GitDiffFromHistoryHandler(@NotNull Project project) {
79     myProject = project;
80     myGit = ServiceManager.getService(project, Git.class);
81     myRepositoryManager = GitUtil.getRepositoryManager(project);
82   }
83
84   @Override
85   public void showDiffForOne(@NotNull AnActionEvent e, @NotNull FilePath filePath,
86                              @NotNull VcsFileRevision previousRevision, @NotNull VcsFileRevision revision) {
87     GitFileRevision rev = (GitFileRevision)revision;
88     Collection<String> parents = rev.getParents();
89     if (parents.size() < 2) {
90       doShowDiff(filePath, previousRevision, revision, false);
91     }
92     else { // merge 
93       showDiffForMergeCommit(e, filePath, rev, parents);
94     }
95   }
96
97   @Override
98   public void showDiffForTwo(@NotNull FilePath filePath, @NotNull VcsFileRevision revision1, @NotNull VcsFileRevision revision2) {
99     doShowDiff(filePath, revision1, revision2, true);
100   }
101
102   private void doShowDiff(@NotNull FilePath filePath, @NotNull VcsFileRevision revision1, @NotNull VcsFileRevision revision2,
103                           boolean autoSort) {
104     if (!filePath.isDirectory()) {
105       VcsHistoryUtil.showDifferencesInBackground(myProject, filePath, revision1, revision2, autoSort);
106     }
107     else if (revision2 instanceof CurrentRevision) {
108       GitFileRevision left = (GitFileRevision)revision1;
109       showDiffForDirectory(filePath, left.getHash(), null);
110     }
111     else if (revision1.equals(VcsFileRevision.NULL)) {
112       GitFileRevision right = (GitFileRevision)revision2;
113       showDiffForDirectory(filePath, null, right.getHash());
114     }
115     else {
116       GitFileRevision left = (GitFileRevision)revision1;
117       GitFileRevision right = (GitFileRevision)revision2;
118       if (autoSort) {
119         Pair<VcsFileRevision, VcsFileRevision> pair = VcsHistoryUtil.sortRevisions(revision1, revision2);
120         left = (GitFileRevision)pair.first;
121         right = (GitFileRevision)pair.second;
122       }
123       showDiffForDirectory(filePath, left.getHash(), right.getHash());
124     }
125   }
126
127   private void showDiffForDirectory(@NotNull final FilePath path, @Nullable final String hash1, @Nullable final String hash2) {
128     GitRepository repository = getRepository(path);
129     calculateDiffInBackground(repository, path, hash1, hash2, new Consumer<List<Change>>() {
130       @Override
131       public void consume(List<Change> changes) {
132         showDirDiffDialog(path, hash1, hash2, changes);
133       }
134     });
135   }
136
137   @NotNull
138   private GitRepository getRepository(@NotNull FilePath path) {
139     GitRepository repository = myRepositoryManager.getRepositoryForFile(path);
140     LOG.assertTrue(repository != null, "Repository is null for " + path);
141     return repository;
142   }
143
144   // hash1 == null => hash2 is the initial commit
145   // hash2 == null => comparing hash1 with local
146   private void calculateDiffInBackground(@NotNull final GitRepository repository, @NotNull final FilePath path,
147                                          @Nullable  final String hash1, @Nullable final String hash2,
148                                          final Consumer<List<Change>> successHandler) {
149     new Task.Backgroundable(myProject, "Comparing revisions...") {
150       private List<Change> myChanges;
151       @Override
152       public void run(@NotNull ProgressIndicator indicator) {
153         try {
154           if (hash1 != null) {
155             // diff
156             myChanges = new ArrayList<Change>(GitChangeUtils.getDiff(repository.getProject(), repository.getRoot(), hash1, hash2,
157                                                                      Collections.singletonList(path)));
158           }
159           else {
160             // show the initial commit
161             myChanges = new ArrayList<Change>(GitChangeUtils.getRevisionChanges(repository.getProject(), repository.getRoot(), hash2, false,
162                                                                                 true).getChanges());
163           }
164         }
165         catch (VcsException e) {
166           showError(e, "Error during requesting diff for directory");
167         }
168       }
169
170       @Override
171       public void onSuccess() {
172         successHandler.consume(myChanges);
173       }
174     }.queue();
175   }
176
177   private void showDirDiffDialog(@NotNull FilePath path, @Nullable String hash1, @Nullable String hash2, @NotNull List<Change> diff) {
178     DialogBuilder dialogBuilder = new DialogBuilder(myProject);
179     String title;
180     if (hash2 != null) {
181       if (hash1 != null) {
182         title = String.format("Difference between %s and %s in %s", GitUtil.getShortHash(hash1), GitUtil.getShortHash(hash2), path.getName());
183       }
184       else {
185         title = String.format("Initial commit %s in %s", GitUtil.getShortHash(hash2), path.getName());
186       }
187     }
188     else {
189       LOG.assertTrue(hash1 != null, "hash1 and hash2 can't both be null. Path: " + path);
190       title = String.format("Difference between %s and local version in %s", GitUtil.getShortHash(hash1), path.getName());
191     }
192     dialogBuilder.setTitle(title);
193     dialogBuilder.setActionDescriptors(new DialogBuilder.ActionDescriptor[] { new DialogBuilder.CloseDialogAction()});
194     final ChangesBrowser changesBrowser = new ChangesBrowser(myProject, null, diff, null, false, true,
195                                                              null, ChangesBrowser.MyUseCase.COMMITTED_CHANGES, null);
196     changesBrowser.setChangesToDisplay(diff);
197     dialogBuilder.setCenterPanel(changesBrowser);
198     dialogBuilder.show();
199   }
200
201   private void showDiffForMergeCommit(@NotNull final AnActionEvent event, @NotNull final FilePath filePath,
202                                       @NotNull final GitFileRevision rev, @NotNull final Collection<String> parents) {
203
204     checkIfFileWasTouchedAndFindParentsInBackground(filePath, rev, parents, new Consumer<MergeCommitPreCheckInfo>() {
205       @Override
206       public void consume(MergeCommitPreCheckInfo info) {
207         if (!info.wasFileTouched()) {
208           String message = filePath.getName() + " did not change in this merge commit";
209           VcsBalloonProblemNotifier.showOverVersionControlView(GitDiffFromHistoryHandler.this.myProject, message, MessageType.INFO);
210         }
211         showPopup(event, rev, filePath, info.getParents());
212       }
213     });
214   }
215
216   private static class MergeCommitPreCheckInfo {
217     private final boolean myWasFileTouched;
218     private final Collection<GitFileRevision> myParents;
219
220     private MergeCommitPreCheckInfo(boolean touched, Collection<GitFileRevision> parents) {
221       myWasFileTouched = touched;
222       myParents = parents;
223     }
224
225     public boolean wasFileTouched() {
226       return myWasFileTouched;
227     }
228
229     public Collection<GitFileRevision> getParents() {
230       return myParents;
231     }
232   }
233
234   private void checkIfFileWasTouchedAndFindParentsInBackground(@NotNull final FilePath filePath, @NotNull final GitFileRevision rev,
235                                                                @NotNull final Collection<String> parentHashes,
236                                                                @NotNull final Consumer<MergeCommitPreCheckInfo> resultHandler) {
237     new Task.Backgroundable(myProject, "Loading changes...", false) {
238       private MergeCommitPreCheckInfo myInfo;
239
240       @Override public void run(@NotNull ProgressIndicator indicator) {
241         try {
242           GitRepository repository = getRepository(filePath);
243           boolean fileTouched = wasFileTouched(repository, rev);
244           Collection<GitFileRevision> parents = findParentRevisions(repository, rev, parentHashes);
245           myInfo = new MergeCommitPreCheckInfo(fileTouched, parents);
246         }
247         catch (VcsException e) {
248           String logMessage = "Error happened while executing git show " + rev + ":" + filePath;
249           showError(e, logMessage);
250         }
251       }
252
253       @Override
254       public void onSuccess() {
255         if (myInfo != null) { // if info == null => an exception happened
256           resultHandler.consume(myInfo);
257         }
258       }
259     }.queue();
260   }
261
262   @NotNull
263   private Collection<GitFileRevision> findParentRevisions(@NotNull GitRepository repository, @NotNull GitFileRevision currentRevision,
264                                                           @NotNull Collection<String> parentHashes) throws VcsException {
265     // currentRevision is a merge revision.
266     // the file could be renamed in one of the branches, i.e. the name in one of the parent revisions may be different from the name
267     // in currentRevision. It can be different even in both parents, but it would a rename-rename conflict, and we don't handle such anyway.
268
269     Collection<GitFileRevision> parents = new ArrayList<GitFileRevision>(parentHashes.size());
270     for (String parentHash : parentHashes) {
271       parents.add(createParentRevision(repository, currentRevision, parentHash));
272     }
273     return parents;
274   }
275
276   @NotNull
277   private GitFileRevision createParentRevision(@NotNull GitRepository repository, @NotNull GitFileRevision currentRevision,
278                                                @NotNull String parentHash) throws VcsException {
279     FilePath currentRevisionPath = currentRevision.getPath();
280     if (currentRevisionPath.isDirectory()) {
281       // for directories the history doesn't follow renames
282       return makeRevisionFromHash(currentRevisionPath, parentHash);
283     }
284
285     // can't limit by the path: in that case rename information will be missed
286     Collection<Change> changes = GitChangeUtils.getDiff(myProject, repository.getRoot(), parentHash, currentRevision.getHash(), null);
287     for (Change change : changes) {
288       ContentRevision afterRevision = change.getAfterRevision();
289       ContentRevision beforeRevision = change.getBeforeRevision();
290       if (afterRevision != null && afterRevision.getFile().equals(currentRevisionPath)) {
291         // if the file was renamed, taking the path how it was in the parent; otherwise the path didn't change
292         FilePath path = (beforeRevision != null ? beforeRevision.getFile() : afterRevision.getFile());
293         return new GitFileRevision(myProject, path, new GitRevisionNumber(parentHash), true);
294       }
295     }
296     LOG.error(String.format("Could not find parent revision. Will use the path from parent revision. Current revision: %s, parent hash: %s",
297                             currentRevision, parentHash));
298     return makeRevisionFromHash(currentRevisionPath, parentHash);
299   }
300
301
302   private void showError(VcsException e, String logMessage) {
303     LOG.info(logMessage, e);
304     VcsBalloonProblemNotifier.showOverVersionControlView(this.myProject, e.getMessage(), MessageType.ERROR);
305   }
306
307   private void showPopup(@NotNull AnActionEvent event, @NotNull GitFileRevision rev, @NotNull FilePath filePath,
308                          @NotNull Collection<GitFileRevision> parents) {
309     ActionGroup parentActions = createActionGroup(rev, filePath, parents);
310     DataContext dataContext = SimpleDataContext.getProjectContext(myProject);
311     ListPopup popup = JBPopupFactory.getInstance().createActionGroupPopup("Choose parent to compare", parentActions, dataContext,
312                                                                           JBPopupFactory.ActionSelectionAid.NUMBERING, true);
313     showPopupInBestPosition(popup, event, dataContext);
314   }
315
316   private static void showPopupInBestPosition(@NotNull ListPopup popup, @NotNull AnActionEvent event, @NotNull DataContext dataContext) {
317     if (event.getInputEvent() instanceof MouseEvent) {
318       if (!event.getPlace().equals(ActionPlaces.UPDATE_POPUP)) {
319         popup.show(new RelativePoint((MouseEvent)event.getInputEvent()));
320       }
321       else { // quick fix for invoking from the context menu: coordinates are calculated incorrectly there.
322         popup.showInBestPositionFor(dataContext);
323       }
324     }
325     else {
326       popup.showInBestPositionFor(dataContext);
327     }
328   }
329
330   @NotNull
331   private ActionGroup createActionGroup(@NotNull GitFileRevision rev, @NotNull FilePath filePath, @NotNull Collection<GitFileRevision> parents) {
332     Collection<AnAction> actions = new ArrayList<AnAction>(2);
333     for (GitFileRevision parent : parents) {
334       actions.add(createParentAction(rev, filePath, parent));
335     }
336     return new DefaultActionGroup(ArrayUtil.toObjectArray(actions, AnAction.class));
337   }
338
339   @NotNull
340   private AnAction createParentAction(@NotNull GitFileRevision rev, @NotNull FilePath filePath, @NotNull GitFileRevision parent) {
341     return new ShowDiffWithParentAction(filePath, rev, parent);
342   }
343
344   @NotNull
345   private GitFileRevision makeRevisionFromHash(@NotNull FilePath filePath, @NotNull String hash) {
346     return new GitFileRevision(myProject, filePath, new GitRevisionNumber(hash), false);
347   }
348
349   private boolean wasFileTouched(@NotNull GitRepository repository, @NotNull GitFileRevision rev) throws VcsException {
350     GitCommandResult result = myGit.show(repository, rev.getHash());
351     if (result.success()) {
352       return isFilePresentInOutput(repository, rev.getPath(), result.getOutput());
353     }
354     throw new VcsException(result.getErrorOutputAsJoinedString());
355   }
356
357   private static boolean isFilePresentInOutput(@NotNull GitRepository repository, @NotNull FilePath path, @NotNull List<String> output) {
358     String relativePath = getRelativePath(repository, path);
359     for (String line : output) {
360       if (line.startsWith("---") || line.startsWith("+++")) {
361         if (line.contains(relativePath)) {
362           return true;
363         }
364       }
365     }
366     return false;
367   }
368
369   @Nullable
370   private static String getRelativePath(@NotNull GitRepository repository, @NotNull FilePath path) {
371     return FileUtil.getRelativePath(repository.getRoot().getPath(), path.getPath(), '/');
372   }
373
374   private class ShowDiffWithParentAction extends AnAction {
375
376     @NotNull private final FilePath myFilePath;
377     @NotNull private final GitFileRevision myRevision;
378     @NotNull private final GitFileRevision myParentRevision;
379
380     public ShowDiffWithParentAction(@NotNull FilePath filePath, @NotNull GitFileRevision rev, @NotNull GitFileRevision parent) {
381       super(GitUtil.getShortHash(parent.getHash()));
382       myFilePath = filePath;
383       myRevision = rev;
384       myParentRevision = parent;
385     }
386
387     @Override
388     public void actionPerformed(AnActionEvent e) {
389       doShowDiff(myFilePath, myParentRevision, myRevision, false);
390     }
391
392   }
393 }