Git push: remove redundant unused "push all" case
[idea/community.git] / plugins / git4idea / src / git4idea / push / GitPusher.java
1 /*
2  * Copyright 2000-2011 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.push;
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.progress.Task;
22 import com.intellij.openapi.project.Project;
23 import com.intellij.openapi.ui.DialogWrapper;
24 import com.intellij.openapi.vcs.VcsException;
25 import com.intellij.openapi.vcs.update.UpdatedFiles;
26 import com.intellij.openapi.vfs.VirtualFile;
27 import com.intellij.util.ui.UIUtil;
28 import git4idea.GitUtil;
29 import git4idea.commands.Git;
30 import git4idea.GitBranch;
31 import git4idea.GitVcs;
32 import git4idea.branch.GitBranchPair;
33 import git4idea.commands.GitCommandResult;
34 import git4idea.config.GitVcsSettings;
35 import git4idea.config.UpdateMethod;
36 import git4idea.history.GitHistoryUtils;
37 import git4idea.history.browser.GitCommit;
38 import git4idea.jgit.GitHttpAdapter;
39 import git4idea.repo.GitRemote;
40 import git4idea.repo.GitRepository;
41 import git4idea.repo.GitRepositoryManager;
42 import git4idea.settings.GitPushSettings;
43 import git4idea.update.GitUpdateProcess;
44 import org.jetbrains.annotations.NotNull;
45 import org.jetbrains.annotations.Nullable;
46
47 import java.util.*;
48 import java.util.concurrent.atomic.AtomicInteger;
49
50 /**
51  * Collects information to push and performs the push.
52  *
53  * @author Kirill Likhodedov
54  */
55 public final class GitPusher {
56
57   /**
58    * if diff-log is not available (new branch is created, for example), we show a few recent commits made on the branch
59    */
60   static final int RECENT_COMMITS_NUMBER = 5;
61   
62   static final GitBranch NO_TARGET_BRANCH = new GitBranch("", false, true);
63
64   private static final Logger LOG = Logger.getInstance(GitPusher.class);
65   private static final String INDICATOR_TEXT = "Pushing";
66   private static final int MAX_PUSH_ATTEMPTS = 10;
67
68   private final Project myProject;
69   private final ProgressIndicator myProgressIndicator;
70   private final Collection<GitRepository> myRepositories;
71   private final GitVcsSettings mySettings;
72   private final GitPushSettings myPushSettings;
73
74   public static void showPushDialogAndPerformPush(@NotNull final Project project) {
75     final GitPushDialog dialog = new GitPushDialog(project);
76     dialog.show();
77     if (dialog.isOK()) {
78       Task.Backgroundable task = new Task.Backgroundable(project, INDICATOR_TEXT, false) {
79         @Override
80         public void run(@NotNull ProgressIndicator indicator) {
81           new GitPusher(project, indicator).push(dialog.getPushInfo());
82         }
83       };
84       GitVcs.runInBackground(task);
85     }
86   }
87
88   // holds settings chosen in GitRejectedPushUpdate dialog to reuse if the next push is rejected again.
89   static class UpdateSettings {
90     private final boolean myUpdateAllRoots;
91     private final UpdateMethod myUpdateMethod;
92
93     private UpdateSettings(boolean updateAllRoots, UpdateMethod updateMethod) {
94       myUpdateAllRoots = updateAllRoots;
95       myUpdateMethod = updateMethod;
96     }
97
98     public boolean shouldUpdateAllRoots() {
99       return myUpdateAllRoots;
100     }
101
102     public UpdateMethod getUpdateMethod() {
103       return myUpdateMethod;
104     }
105
106     public boolean shouldUpdate() {
107       return getUpdateMethod() != null;
108     }
109
110     @Override
111     public String toString() {
112       return String.format("UpdateSettings{myUpdateAllRoots=%s, myUpdateMethod=%s}", myUpdateAllRoots, myUpdateMethod);
113     }
114   }
115
116   public GitPusher(@NotNull Project project, @NotNull ProgressIndicator indicator) {
117     myProject = project;
118     myProgressIndicator = indicator;
119     myRepositories = GitRepositoryManager.getInstance(project).getRepositories();
120     mySettings = GitVcsSettings.getInstance(myProject);
121     myPushSettings = GitPushSettings.getInstance(myProject);
122   }
123
124   /**
125    * @param pushSpecs which branches in which repositories should be pushed.
126    *                               The most common situation is all repositories in the project with a single currently active branch for
127    *                               each of them.
128    * @throws VcsException if couldn't query 'git log' about commits to be pushed.
129    * @return
130    */
131   @NotNull
132   GitCommitsByRepoAndBranch collectCommitsToPush(@NotNull Map<GitRepository, GitPushSpec> pushSpecs) throws VcsException {
133     Map<GitRepository, List<GitBranchPair>> reposAndBranchesToPush = prepareRepositoriesAndBranchesToPush(pushSpecs);
134     
135     Map<GitRepository, GitCommitsByBranch> commitsByRepoAndBranch = new HashMap<GitRepository, GitCommitsByBranch>();
136     for (GitRepository repository : myRepositories) {
137       List<GitBranchPair> branchPairs = reposAndBranchesToPush.get(repository);
138       if (branchPairs == null) {
139         continue;
140       }
141       GitCommitsByBranch commitsByBranch = collectsCommitsToPush(repository, branchPairs);
142       commitsByRepoAndBranch.put(repository, commitsByBranch);
143     }
144     return new GitCommitsByRepoAndBranch(commitsByRepoAndBranch);
145   }
146
147   @NotNull
148   private Map<GitRepository, List<GitBranchPair>> prepareRepositoriesAndBranchesToPush(@NotNull Map<GitRepository, GitPushSpec> pushSpecs) throws VcsException {
149     Map<GitRepository, List<GitBranchPair>> res = new HashMap<GitRepository, List<GitBranchPair>>();
150     for (GitRepository repository : myRepositories) {
151       GitPushSpec pushSpec = pushSpecs.get(repository);
152       if (pushSpec == null) {
153         continue;
154       }
155       res.put(repository, Collections.singletonList(new GitBranchPair(pushSpec.getSource(), pushSpec.getDest())));
156     }
157     return res;
158   }
159
160   @NotNull
161   private GitCommitsByBranch collectsCommitsToPush(@NotNull GitRepository repository, @NotNull List<GitBranchPair> sourcesDestinations)
162     throws VcsException {
163     Map<GitBranch, GitPushBranchInfo> commitsByBranch = new HashMap<GitBranch, GitPushBranchInfo>();
164
165     for (GitBranchPair sourceDest : sourcesDestinations) {
166       GitBranch source = sourceDest.getBranch();
167       GitBranch dest = sourceDest.getDest();
168       assert dest != null : "Destination branch can't be null here for branch " + source;
169
170       List<GitCommit> commits;
171       GitPushBranchInfo.Type type;
172       if (dest == NO_TARGET_BRANCH) {
173         commits = collectRecentCommitsOnBranch(repository, source);
174         type = GitPushBranchInfo.Type.NO_TRACKED_OR_TARGET;
175       }
176       else if (GitUtil.repoContainsRemoteBranch(repository, dest)) {
177         commits = collectCommitsToPush(repository, source.getName(), dest.getName());
178         type = GitPushBranchInfo.Type.STANDARD;
179       } 
180       else {
181         commits = collectRecentCommitsOnBranch(repository, source);
182         type = GitPushBranchInfo.Type.NEW_BRANCH;
183       }
184       commitsByBranch.put(source, new GitPushBranchInfo(source, dest, commits, type));
185     }
186
187     return new GitCommitsByBranch(commitsByBranch);
188   }
189
190   private List<GitCommit> collectRecentCommitsOnBranch(GitRepository repository, GitBranch source) throws VcsException {
191     return GitHistoryUtils.history(myProject, repository.getRoot(), "--max-count=" + RECENT_COMMITS_NUMBER, source.getName());
192   }
193
194   @NotNull
195   private List<GitCommit> collectCommitsToPush(@NotNull GitRepository repository, @NotNull String source, @NotNull String destination)
196     throws VcsException {
197     return GitHistoryUtils.history(myProject, repository.getRoot(), destination + ".." + source);
198   }
199
200   /**
201    * Makes push, shows the result in a notification. If push for current branch is rejected, shows a dialog proposing to update.
202    */
203   public void push(@NotNull GitPushInfo pushInfo) {
204     push(pushInfo, null, null, 0);
205   }
206
207   /**
208    * Makes push, shows the result in a notification. If push for current branch is rejected, shows a dialog proposing to update.
209    * If {@code previousResult} and {@code updateSettings} are set, it means that this push is not the first, but is after a successful update.
210    * In that case, if push is rejected again, the dialog is not shown, and update is performed automatically with the previously chosen
211    * option.
212    * Also, at the end results are merged and are shown in a single notification.
213    */
214   private void push(@NotNull GitPushInfo pushInfo, @Nullable GitPushResult previousResult, @Nullable UpdateSettings updateSettings, int attempt) {
215     GitPushResult result = tryPushAndGetResult(pushInfo);
216     handleResult(pushInfo, result, previousResult, updateSettings, attempt);
217   }
218
219   @NotNull
220   private GitPushResult tryPushAndGetResult(@NotNull GitPushInfo pushInfo) {
221     GitPushResult pushResult = new GitPushResult(myProject);
222     
223     GitCommitsByRepoAndBranch commits = pushInfo.getCommits();
224     for (GitRepository repository : commits.getRepositories()) {
225       if (commits.get(repository).getAllCommits().size() == 0) {  // don't push repositories where there is nothing to push. Note that when a branch is created, several recent commits are stored in the pushInfo.
226         continue;
227       }
228       GitPushRepoResult repoResult = pushRepository(pushInfo, commits, repository);
229       if (repoResult.getType() == GitPushRepoResult.Type.NOT_PUSHING) {
230         continue;
231       }
232       pushResult.append(repository, repoResult);
233       GitPushRepoResult.Type resultType = repoResult.getType();
234       if (resultType == GitPushRepoResult.Type.CANCEL || resultType == GitPushRepoResult.Type.NOT_AUTHORIZED) { // don't proceed if user has cancelled or couldn't login
235         break;
236       }
237     }
238     GitRepositoryManager.getInstance(myProject).updateAllRepositories(GitRepository.TrackedTopic.BRANCHES); // new remote branch may be created
239     return pushResult;
240   }
241
242   @NotNull
243   private static GitPushRepoResult pushRepository(@NotNull GitPushInfo pushInfo,
244                                                   @NotNull GitCommitsByRepoAndBranch commits,
245                                                   @NotNull GitRepository repository) {
246     GitPushSpec pushSpec = pushInfo.getPushSpecs().get(repository);
247     GitSimplePushResult simplePushResult = pushAndGetSimpleResult(repository, pushSpec, commits.get(repository));
248     String output = simplePushResult.getOutput();
249     switch (simplePushResult.getType()) {
250       case SUCCESS:
251         return successOrErrorRepoResult(commits, repository, output, true);
252       case ERROR:
253         return successOrErrorRepoResult(commits, repository, output, false);
254       case REJECT:
255         return getResultFromRejectedPush(commits, repository, simplePushResult);
256       case NOT_AUTHORIZED:
257         return GitPushRepoResult.notAuthorized(output);
258       case CANCEL:
259         return GitPushRepoResult.cancelled(output);
260       case NOT_PUSHED:
261         return GitPushRepoResult.notPushed();
262       default:
263         return GitPushRepoResult.cancelled(output);
264     }
265   }
266
267   @NotNull
268   private static GitPushRepoResult getResultFromRejectedPush(@NotNull GitCommitsByRepoAndBranch commits,
269                                                              @NotNull GitRepository repository,
270                                                              @NotNull GitSimplePushResult simplePushResult) {
271     Collection<String> rejectedBranches = simplePushResult.getRejectedBranches();
272
273     Map<GitBranch, GitPushBranchResult> resultMap = new HashMap<GitBranch, GitPushBranchResult>();
274     GitCommitsByBranch commitsByBranch = commits.get(repository);
275     boolean pushedBranchWasRejected = false;
276     for (GitBranch branch : commitsByBranch.getBranches()) {
277       GitPushBranchResult branchResult;
278       if (branchInRejected(branch, rejectedBranches)) {
279         branchResult = GitPushBranchResult.rejected();
280         pushedBranchWasRejected = true;
281       }
282       else {
283         branchResult = successfulResultForBranch(commitsByBranch, branch);
284       }
285       resultMap.put(branch, branchResult);
286     }
287
288     if (pushedBranchWasRejected) {
289       return GitPushRepoResult.someRejected(resultMap, simplePushResult.getOutput());
290     } else {
291       // The rejectedDetector detected rejected push of the branch which had nothing to push (but is behind the upstream). We are not counting it.
292       return GitPushRepoResult.success(resultMap, simplePushResult.getOutput());
293     }
294   }
295
296   @NotNull
297   private static GitSimplePushResult pushAndGetSimpleResult(GitRepository repository, GitPushSpec pushSpec, GitCommitsByBranch commitsByBranch) {
298     if (pushSpec.getDest() == NO_TARGET_BRANCH) {
299       return GitSimplePushResult.notPushed();
300     }
301
302     GitRemote remote = pushSpec.getRemote();
303     String httpUrl = null;
304     for (String pushUrl : remote.getPushUrls()) {
305       if (GitHttpAdapter.shouldUseJGit(pushUrl)) {
306         httpUrl = pushUrl;
307         break;            // TODO support http and ssh urls in one origin
308       }
309     }
310     if (httpUrl != null) {
311       return GitHttpAdapter.push(repository, remote, httpUrl, formPushSpec(pushSpec, remote));
312     }
313     else {
314       return pushNatively(repository, pushSpec);
315     }
316   }
317
318   @NotNull
319   private static String formPushSpec(@NotNull GitPushSpec spec, @NotNull GitRemote remote) {
320     String destWithRemote = spec.getDest().getName();
321     String prefix = remote.getName() + "/";
322     String destName;
323     if (destWithRemote.startsWith(prefix)) {
324       destName = destWithRemote.substring(prefix.length());
325     }
326     else {
327       LOG.error("Destination remote branch has invalid name. Remote branch name: " + destWithRemote + "\nRemote: " + remote);
328       destName = destWithRemote;
329     }
330     return spec.getSource().getName() + ":" + destName;
331   }
332
333   @NotNull
334   private static GitSimplePushResult pushNatively(GitRepository repository, GitPushSpec pushSpec) {
335     GitPushRejectedDetector rejectedDetector = new GitPushRejectedDetector();
336     GitCommandResult res = Git.push(repository, pushSpec, rejectedDetector);
337     if (rejectedDetector.rejected()) {
338       Collection<String> rejectedBranches = rejectedDetector.getRejectedBranches();
339       return GitSimplePushResult.reject(rejectedBranches);
340     }
341     else if (res.success()) {
342       return GitSimplePushResult.success();
343     }
344     else {
345       return GitSimplePushResult.error(res.getErrorOutputAsHtmlString());
346     }
347   }
348
349   @NotNull
350   private static GitPushRepoResult successOrErrorRepoResult(@NotNull GitCommitsByRepoAndBranch commits, @NotNull GitRepository repository, @NotNull String output, boolean success) {
351     GitPushRepoResult repoResult;
352     Map<GitBranch, GitPushBranchResult> resultMap = new HashMap<GitBranch, GitPushBranchResult>();
353     GitCommitsByBranch commitsByBranch = commits.get(repository);
354     for (GitBranch branch : commitsByBranch.getBranches()) {
355       GitPushBranchResult branchResult = success ?
356                                          successfulResultForBranch(commitsByBranch, branch) :
357                                          GitPushBranchResult.error();
358       resultMap.put(branch, branchResult);
359     }
360     repoResult = success ? GitPushRepoResult.success(resultMap, output) : GitPushRepoResult.error(resultMap,  output);
361     return repoResult;
362   }
363
364   @NotNull
365   private static GitPushBranchResult successfulResultForBranch(@NotNull GitCommitsByBranch commitsByBranch, @NotNull GitBranch branch) {
366     GitPushBranchInfo branchInfo = commitsByBranch.get(branch);
367     if (branchInfo.isNewBranchCreated()) {
368       return GitPushBranchResult.newBranch(branchInfo.getDestBranch().getName());
369     } 
370     return GitPushBranchResult.success(branchInfo.getCommits().size());
371   }
372
373   private static boolean branchInRejected(@NotNull GitBranch branch, @NotNull Collection<String> rejectedBranches) {
374     String branchName = branch.getName();
375     final String REFS_HEADS = "refs/heads/";
376     if (branchName.startsWith(REFS_HEADS)) {
377       branchName = branchName.substring(REFS_HEADS.length());
378     }
379     
380     for (String rejectedBranch : rejectedBranches) {
381       if (rejectedBranch.equals(branchName) || (rejectedBranch.startsWith(REFS_HEADS) &&  rejectedBranch.substring(REFS_HEADS.length()).equals(branchName))) {
382         return true;
383       }
384     }
385     return false;
386   }
387
388   // if all repos succeeded, show notification.
389   // if all repos failed, show notification.
390   // if some repos failed, show notification with both results.
391   // if in failed repos, some branches were rejected, it is a warning-type, but not an error.
392   // if in a failed repo, current branch was rejected, propose to update the branch. Don't do it if not current branch was rejected,
393   // since it is difficult to update such a branch.
394   // if in a failed repo, a branch was rejected that had nothing to push, don't notify about the rejection.
395   // Besides all of the above, don't confuse users with 1 repository with all this "repository/root" stuff;
396   // don't confuse users which push only a single branch with all this "branch" stuff.
397   private void handleResult(@NotNull GitPushInfo pushInfo, @NotNull GitPushResult result, @Nullable GitPushResult previousResult, @Nullable UpdateSettings updateSettings, 
398                             int pushAttempt) {
399     result.mergeFrom(previousResult);
400
401     if (result.isEmpty()) {
402       GitVcs.NOTIFICATION_GROUP_ID.createNotification("Nothing to push", NotificationType.INFORMATION).notify(myProject);
403     }
404     else if (result.wasErrorCancelOrNotAuthorized()) {
405       // if there was an error on any repo, we won't propose to update even if current branch of a repo was rejected
406       result.createNotification().notify(myProject);
407     }
408     else {
409       // there were no errors, but there might be some rejected branches on some of the repositories
410       // => for current branch propose to update and re-push it. For others just warn
411       Map<GitRepository, GitBranch> rejectedPushesForCurrentBranch = result.getRejectedPushesFromCurrentBranchToTrackedBranch(pushInfo);
412
413       if (pushAttempt <= MAX_PUSH_ATTEMPTS && !rejectedPushesForCurrentBranch.isEmpty()) {
414
415         LOG.info(
416           String.format("Rejected pushes for current branches: %n%s%nUpdate settings: %s", rejectedPushesForCurrentBranch, updateSettings));
417         
418         if (updateSettings == null) {
419           // show dialog only when push is rejected for the first time in a row, otherwise reuse previously chosen update method
420           // and don't show the dialog again if user has chosen not to ask again
421           updateSettings = readUpdateSettings();
422           if (!mySettings.autoUpdateIfPushRejected()) {
423             final GitRejectedPushUpdateDialog dialog = new GitRejectedPushUpdateDialog(myProject, rejectedPushesForCurrentBranch.keySet(), updateSettings);
424             final int exitCode = showDialogAndGetExitCode(dialog);
425             updateSettings = new UpdateSettings(dialog.shouldUpdateAll(), getUpdateMethodFromDialogExitCode(exitCode));
426             saveUpdateSettings(updateSettings);
427           }
428         } 
429
430         if (updateSettings.shouldUpdate()) {
431           Collection<GitRepository> repositoriesToUpdate = getRootsToUpdate(rejectedPushesForCurrentBranch, updateSettings.shouldUpdateAllRoots());
432           GitPushResult adjustedPushResult = result.remove(rejectedPushesForCurrentBranch);
433           adjustedPushResult.markUpdateStartIfNotMarked(repositoriesToUpdate);
434           boolean updateResult = update(getRootsFromRepositories(repositoriesToUpdate), updateSettings.getUpdateMethod());
435           if (updateResult) {
436             myProgressIndicator.setText(INDICATOR_TEXT);
437             GitPushInfo newPushInfo = pushInfo.retain(rejectedPushesForCurrentBranch);
438             push(newPushInfo, adjustedPushResult, updateSettings, pushAttempt + 1);
439             return; // don't notify - next push will notify all results in compound
440           }
441         }
442
443       }
444
445       result.createNotification().notify(myProject);
446     }
447   }
448
449   private void saveUpdateSettings(@NotNull UpdateSettings updateSettings) {
450     myPushSettings.setUpdateAllRoots(updateSettings.shouldUpdateAllRoots());
451     myPushSettings.setUpdateMethod(updateSettings.getUpdateMethod());
452   }
453
454   @NotNull
455   private UpdateSettings readUpdateSettings() {
456     boolean updateAllRoots = myPushSettings.shouldUpdateAllRoots();
457     UpdateMethod updateMethod = myPushSettings.getUpdateMethod();
458     return new UpdateSettings(updateAllRoots, updateMethod);
459   }
460
461   private int showDialogAndGetExitCode(@NotNull final GitRejectedPushUpdateDialog dialog) {
462     final AtomicInteger exitCode = new AtomicInteger();
463     UIUtil.invokeAndWaitIfNeeded(new Runnable() {
464       @Override
465       public void run() {
466         dialog.show();
467         exitCode.set(dialog.getExitCode());
468       }
469     });
470     int code = exitCode.get();
471     if (code != DialogWrapper.CANCEL_EXIT_CODE) {
472       mySettings.setAutoUpdateIfPushRejected(dialog.shouldAutoUpdateInFuture());
473     }
474     return code;
475   }
476
477   /**
478    * @return update method selected in the dialog or {@code null} if user pressed Cancel, i.e. doesn't want to update.
479    */
480   @Nullable
481   private static UpdateMethod getUpdateMethodFromDialogExitCode(int exitCode) {
482     switch (exitCode) {
483       case GitRejectedPushUpdateDialog.MERGE_EXIT_CODE:  return UpdateMethod.MERGE;
484       case GitRejectedPushUpdateDialog.REBASE_EXIT_CODE: return UpdateMethod.REBASE;
485     }
486     return null;
487   }
488
489   @NotNull
490   private Collection<GitRepository> getRootsToUpdate(@NotNull Map<GitRepository, GitBranch> rejectedPushesForCurrentBranch, boolean updateAllRoots) {
491     return updateAllRoots ? myRepositories : rejectedPushesForCurrentBranch.keySet();
492   }
493   
494   @NotNull
495   private static Collection<VirtualFile> getRootsFromRepositories(@NotNull Collection<GitRepository> repositories) {
496     Collection<VirtualFile> roots = new ArrayList<VirtualFile>();
497     for (GitRepository repository : repositories) {
498       roots.add(repository.getRoot());
499     }
500     return roots;
501   }
502
503   private boolean update(@NotNull Collection<VirtualFile> rootsToUpdate, @NotNull UpdateMethod updateMethod) {
504     GitUpdateProcess.UpdateMethod um = updateMethod == UpdateMethod.MERGE ? GitUpdateProcess.UpdateMethod.MERGE : GitUpdateProcess.UpdateMethod.REBASE;
505     boolean updateResult = new GitUpdateProcess(myProject, myProgressIndicator, new HashSet<VirtualFile>(rootsToUpdate), UpdatedFiles.create()).update(um);
506     for (VirtualFile virtualFile : rootsToUpdate) {
507       virtualFile.refresh(true, true);
508     }
509     return updateResult;
510   }
511
512 }