blob: 1dfcdfaf327d44d39004a9eef648bbb71b9195d1 [file] [log] [blame]
/*
* Copyright 2000-2014 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.push;
import com.intellij.openapi.components.ServiceManager;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.progress.ProgressIndicator;
import com.intellij.openapi.progress.Task;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.ui.DialogWrapper;
import com.intellij.openapi.vcs.VcsException;
import com.intellij.openapi.vcs.VcsNotifier;
import com.intellij.openapi.vcs.update.UpdatedFiles;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.util.ui.UIUtil;
import git4idea.*;
import git4idea.branch.GitBranchPair;
import git4idea.commands.Git;
import git4idea.commands.GitCommandResult;
import git4idea.commands.GitLineHandlerListener;
import git4idea.commands.GitStandardProgressAnalyzer;
import git4idea.config.GitConfigUtil;
import git4idea.config.GitVcsSettings;
import git4idea.config.UpdateMethod;
import git4idea.history.GitHistoryUtils;
import git4idea.repo.GitBranchTrackInfo;
import git4idea.repo.GitRemote;
import git4idea.repo.GitRepository;
import git4idea.repo.GitRepositoryManager;
import git4idea.settings.GitPushSettings;
import git4idea.update.GitUpdateProcess;
import git4idea.update.GitUpdateResult;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.*;
import java.util.concurrent.atomic.AtomicReference;
/**
* Collects information to push and performs the push.
*/
public final class GitPusher {
/**
* if diff-log is not available (new branch is created, for example), we show a few recent commits made on the branch
*/
static final int RECENT_COMMITS_NUMBER = 5;
@Deprecated
static final GitRemoteBranch NO_TARGET_BRANCH = new GitStandardRemoteBranch(GitRemote.DOT, "", GitBranch.DUMMY_HASH);
private static final Logger LOG = Logger.getInstance(GitPusher.class);
private static final String INDICATOR_TEXT = "Pushing";
private static final int MAX_PUSH_ATTEMPTS = 10;
@NotNull private final Project myProject;
@NotNull private final GitRepositoryManager myRepositoryManager;
@NotNull private final ProgressIndicator myProgressIndicator;
@NotNull private final Collection<GitRepository> myRepositories;
@NotNull private final GitVcsSettings mySettings;
@NotNull private final GitPushSettings myPushSettings;
@NotNull private final Git myGit;
@NotNull private final GitPlatformFacade myPlatformFacade;
public static void showPushDialogAndPerformPush(@NotNull Project project, @NotNull GitPlatformFacade facade) {
final GitPushDialog dialog = new GitPushDialog(project);
dialog.show();
if (dialog.isOK()) {
runPushInBackground(project, facade, dialog);
}
}
private static void runPushInBackground(@NotNull final Project project, @NotNull final GitPlatformFacade facade,
@NotNull final GitPushDialog dialog) {
Task.Backgroundable task = new Task.Backgroundable(project, INDICATOR_TEXT, false) {
@Override
public void run(@NotNull ProgressIndicator indicator) {
new GitPusher(project, facade, indicator).push(dialog.getPushInfo());
}
};
GitVcs.runInBackground(task);
}
// holds settings chosen in GitRejectedPushUpdate dialog to reuse if the next push is rejected again.
static class UpdateSettings {
private final boolean myUpdateAllRoots;
private final UpdateMethod myUpdateMethod;
private UpdateSettings(boolean updateAllRoots, UpdateMethod updateMethod) {
myUpdateAllRoots = updateAllRoots;
myUpdateMethod = updateMethod;
}
public boolean shouldUpdateAllRoots() {
return myUpdateAllRoots;
}
public UpdateMethod getUpdateMethod() {
return myUpdateMethod;
}
public boolean shouldUpdate() {
return getUpdateMethod() != null;
}
@Override
public String toString() {
return String.format("UpdateSettings{myUpdateAllRoots=%s, myUpdateMethod=%s}", myUpdateAllRoots, myUpdateMethod);
}
}
public GitPusher(@NotNull Project project, @NotNull GitPlatformFacade facade, @NotNull ProgressIndicator indicator) {
myProject = project;
myPlatformFacade = facade;
myProgressIndicator = indicator;
myRepositoryManager = GitUtil.getRepositoryManager(myProject);
myRepositories = myRepositoryManager.getRepositories();
mySettings = GitVcsSettings.getInstance(myProject);
myPushSettings = GitPushSettings.getInstance(myProject);
myGit = ServiceManager.getService(Git.class);
}
/**
* @param pushSpecs which branches in which repositories should be pushed.
* The most common situation is all repositories in the project with a single currently active branch for
* each of them.
* @throws VcsException if couldn't query 'git log' about commits to be pushed.
* @return
*/
@NotNull
GitCommitsByRepoAndBranch collectCommitsToPush(@NotNull Map<GitRepository, GitPushSpec> pushSpecs) throws VcsException {
Map<GitRepository, List<GitBranchPair>> reposAndBranchesToPush = prepareRepositoriesAndBranchesToPush(pushSpecs);
Map<GitRepository, GitCommitsByBranch> commitsByRepoAndBranch = new HashMap<GitRepository, GitCommitsByBranch>();
for (GitRepository repository : myRepositories) {
List<GitBranchPair> branchPairs = reposAndBranchesToPush.get(repository);
if (branchPairs == null) {
continue;
}
GitCommitsByBranch commitsByBranch = collectsCommitsToPush(repository, branchPairs);
commitsByRepoAndBranch.put(repository, commitsByBranch);
}
return new GitCommitsByRepoAndBranch(commitsByRepoAndBranch);
}
@NotNull
private Map<GitRepository, List<GitBranchPair>> prepareRepositoriesAndBranchesToPush(@NotNull Map<GitRepository, GitPushSpec> pushSpecs) throws VcsException {
Map<GitRepository, List<GitBranchPair>> res = new HashMap<GitRepository, List<GitBranchPair>>();
for (GitRepository repository : myRepositories) {
GitPushSpec pushSpec = pushSpecs.get(repository);
if (pushSpec == null) {
continue;
}
res.put(repository, Collections.singletonList(new GitBranchPair(pushSpec.getSource(), pushSpec.getDest())));
}
return res;
}
@NotNull
private GitCommitsByBranch collectsCommitsToPush(@NotNull GitRepository repository, @NotNull List<GitBranchPair> sourcesDestinations)
throws VcsException {
Map<GitBranch, GitPushBranchInfo> commitsByBranch = new HashMap<GitBranch, GitPushBranchInfo>();
for (GitBranchPair sourceDest : sourcesDestinations) {
GitLocalBranch source = sourceDest.getBranch();
GitRemoteBranch dest = sourceDest.getDest();
assert dest != null : "Destination branch can't be null here for branch " + source;
List<GitCommit> commits;
GitPushBranchInfo.Type type;
if (dest == NO_TARGET_BRANCH) {
commits = collectRecentCommitsOnBranch(repository, source);
type = GitPushBranchInfo.Type.NO_TRACKED_OR_TARGET;
}
else if (GitUtil.repoContainsRemoteBranch(repository, dest)) {
commits = collectCommitsToPush(repository, source.getName(), dest.getName());
type = GitPushBranchInfo.Type.STANDARD;
}
else {
commits = collectRecentCommitsOnBranch(repository, source);
type = GitPushBranchInfo.Type.NEW_BRANCH;
}
commitsByBranch.put(source, new GitPushBranchInfo(source, dest, commits, type));
}
return new GitCommitsByBranch(commitsByBranch);
}
private List<GitCommit> collectRecentCommitsOnBranch(GitRepository repository, GitBranch source) throws VcsException {
return GitHistoryUtils.history(myProject, repository.getRoot(), "--max-count=" + RECENT_COMMITS_NUMBER, source.getName());
}
@NotNull
private List<GitCommit> collectCommitsToPush(@NotNull GitRepository repository, @NotNull String source, @NotNull String destination)
throws VcsException {
return GitHistoryUtils.history(myProject, repository.getRoot(), destination + ".." + source);
}
/**
* Makes push, shows the result in a notification. If push for current branch is rejected, shows a dialog proposing to update.
*/
public void push(@NotNull GitPushInfo pushInfo) {
push(pushInfo, null, null, 0);
}
/**
* Makes push, shows the result in a notification. If push for current branch is rejected, shows a dialog proposing to update.
* If {@code previousResult} and {@code updateSettings} are set, it means that this push is not the first, but is after a successful update.
* In that case, if push is rejected again, the dialog is not shown, and update is performed automatically with the previously chosen
* option.
* Also, at the end results are merged and are shown in a single notification.
*/
private void push(@NotNull GitPushInfo pushInfo, @Nullable GitPushResult previousResult, @Nullable UpdateSettings updateSettings, int attempt) {
GitPushResult result = tryPushAndGetResult(pushInfo);
handleResult(pushInfo, result, previousResult, updateSettings, attempt);
}
@NotNull
private GitPushResult tryPushAndGetResult(@NotNull GitPushInfo pushInfo) {
GitPushResult pushResult = new GitPushResult(myProject);
GitCommitsByRepoAndBranch commits = pushInfo.getCommits();
for (GitRepository repository : commits.getRepositories()) {
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.
continue;
}
GitPushRepoResult repoResult = pushRepository(pushInfo, commits, repository);
if (repoResult.getType() == GitPushRepoResult.Type.NOT_PUSHING) {
continue;
}
pushResult.append(repository, repoResult);
GitPushRepoResult.Type resultType = repoResult.getType();
if (resultType == GitPushRepoResult.Type.CANCEL || resultType == GitPushRepoResult.Type.NOT_AUTHORIZED) { // don't proceed if user has cancelled or couldn't login
break;
}
}
myRepositoryManager.updateAllRepositories(); // new remote branch may be created
return pushResult;
}
@NotNull
private GitPushRepoResult pushRepository(@NotNull GitPushInfo pushInfo,
@NotNull GitCommitsByRepoAndBranch commits,
@NotNull GitRepository repository) {
GitPushSpec pushSpec = pushInfo.getPushSpecs().get(repository);
GitSimplePushResult simplePushResult = pushAndGetSimpleResult(repository, pushSpec);
String output = simplePushResult.getOutput();
switch (simplePushResult.getType()) {
case SUCCESS:
return successOrErrorRepoResult(commits, repository, output, true);
case ERROR:
return successOrErrorRepoResult(commits, repository, output, false);
case REJECT:
return getResultFromRejectedPush(commits, repository, simplePushResult);
case NOT_AUTHORIZED:
return GitPushRepoResult.notAuthorized(output);
case CANCEL:
return GitPushRepoResult.cancelled(output);
case NOT_PUSHED:
return GitPushRepoResult.notPushed();
default:
return GitPushRepoResult.cancelled(output);
}
}
@NotNull
private static GitPushRepoResult getResultFromRejectedPush(@NotNull GitCommitsByRepoAndBranch commits,
@NotNull GitRepository repository,
@NotNull GitSimplePushResult simplePushResult) {
Collection<String> rejectedBranches = simplePushResult.getRejectedBranches();
Map<GitBranch, GitPushBranchResult> resultMap = new HashMap<GitBranch, GitPushBranchResult>();
GitCommitsByBranch commitsByBranch = commits.get(repository);
boolean pushedBranchWasRejected = false;
for (GitBranch branch : commitsByBranch.getBranches()) {
GitPushBranchResult branchResult;
if (branchInRejected(branch, rejectedBranches)) {
branchResult = GitPushBranchResult.rejected();
pushedBranchWasRejected = true;
}
else {
branchResult = successfulResultForBranch(commitsByBranch, branch);
}
resultMap.put(branch, branchResult);
}
if (pushedBranchWasRejected) {
return GitPushRepoResult.someRejected(resultMap, simplePushResult.getOutput());
} else {
// The rejectedDetector detected rejected push of the branch which had nothing to push (but is behind the upstream). We are not counting it.
return GitPushRepoResult.success(resultMap, simplePushResult.getOutput());
}
}
@NotNull
private GitSimplePushResult pushAndGetSimpleResult(@NotNull GitRepository repository, @NotNull GitPushSpec pushSpec) {
if (pushSpec.getDest() == NO_TARGET_BRANCH) {
return GitSimplePushResult.notPushed();
}
GitRemote remote = pushSpec.getRemote();
Collection<String> pushUrls = remote.getPushUrls();
if (pushUrls.isEmpty()) {
LOG.error("No urls or pushUrls are defined for " + remote);
return GitSimplePushResult.error("There are no URLs defined for remote " + remote.getName());
}
String url = pushUrls.iterator().next();
GitSimplePushResult pushResult;
pushResult = pushNatively(repository, pushSpec, url);
if (pushResult.getType() == GitSimplePushResult.Type.SUCCESS) {
setUpstream(repository, pushSpec.getSource(), pushSpec.getRemote(), pushSpec.getDest());
}
return pushResult;
}
private static void setUpstream(@NotNull GitRepository repository,
@NotNull GitLocalBranch source, @NotNull GitRemote remote, @NotNull GitRemoteBranch dest) {
if (!branchTrackingInfoIsSet(repository, source)) {
Project project = repository.getProject();
VirtualFile root = repository.getRoot();
String branchName = source.getName();
try {
boolean rebase = getMergeOrRebaseConfig(project, root);
GitConfigUtil.setValue(project, root, "branch." + branchName + ".remote", remote.getName());
GitConfigUtil.setValue(project, root, "branch." + branchName + ".merge",
GitBranch.REFS_HEADS_PREFIX + dest.getNameForRemoteOperations());
if (rebase) {
GitConfigUtil.setValue(project, root, "branch." + branchName + ".rebase", "true");
}
}
catch (VcsException e) {
LOG.error(String.format("Couldn't set up tracking for source branch %s, target branch %s, remote %s in root %s",
source, dest, remote, repository), e);
VcsNotifier.getInstance(project).notifyWeakError("Couldn't set up branch tracking");
}
}
}
private static boolean getMergeOrRebaseConfig(Project project, VirtualFile root) throws VcsException {
String autoSetupRebase = GitConfigUtil.getValue(project, root, GitConfigUtil.BRANCH_AUTOSETUP_REBASE);
return autoSetupRebase != null && (autoSetupRebase.equals("remote") || autoSetupRebase.equals("always"));
}
private static boolean branchTrackingInfoIsSet(@NotNull GitRepository repository, @NotNull GitLocalBranch source) {
for (GitBranchTrackInfo trackInfo : repository.getBranchTrackInfos()) {
if (trackInfo.getLocalBranch().equals(source)) {
return true;
}
}
return false;
}
@NotNull
private GitSimplePushResult pushNatively(GitRepository repository, GitPushSpec pushSpec, @NotNull String url) {
GitPushRejectedDetector rejectedDetector = new GitPushRejectedDetector();
GitLineHandlerListener progressListener = GitStandardProgressAnalyzer.createListener(myProgressIndicator);
GitCommandResult res = myGit.push(repository, pushSpec, url, rejectedDetector, progressListener);
if (rejectedDetector.rejected()) {
Collection<String> rejectedBranches = rejectedDetector.getRejectedBranches();
return GitSimplePushResult.reject(rejectedBranches);
}
else if (res.success()) {
return GitSimplePushResult.success();
}
else {
return GitSimplePushResult.error(res.getErrorOutputAsHtmlString());
}
}
@NotNull
private static GitPushRepoResult successOrErrorRepoResult(@NotNull GitCommitsByRepoAndBranch commits, @NotNull GitRepository repository, @NotNull String output, boolean success) {
GitPushRepoResult repoResult;
Map<GitBranch, GitPushBranchResult> resultMap = new HashMap<GitBranch, GitPushBranchResult>();
GitCommitsByBranch commitsByBranch = commits.get(repository);
for (GitBranch branch : commitsByBranch.getBranches()) {
GitPushBranchResult branchResult = success ?
successfulResultForBranch(commitsByBranch, branch) :
GitPushBranchResult.error();
resultMap.put(branch, branchResult);
}
repoResult = success ? GitPushRepoResult.success(resultMap, output) : GitPushRepoResult.error(resultMap, output);
return repoResult;
}
@NotNull
private static GitPushBranchResult successfulResultForBranch(@NotNull GitCommitsByBranch commitsByBranch, @NotNull GitBranch branch) {
GitPushBranchInfo branchInfo = commitsByBranch.get(branch);
if (branchInfo.isNewBranchCreated()) {
return GitPushBranchResult.newBranch(branchInfo.getDestBranch().getName());
}
return GitPushBranchResult.success(branchInfo.getCommits().size());
}
private static boolean branchInRejected(@NotNull GitBranch branch, @NotNull Collection<String> rejectedBranches) {
String branchName = branch.getName();
final String REFS_HEADS = "refs/heads/";
if (branchName.startsWith(REFS_HEADS)) {
branchName = branchName.substring(REFS_HEADS.length());
}
for (String rejectedBranch : rejectedBranches) {
if (rejectedBranch.equals(branchName) || (rejectedBranch.startsWith(REFS_HEADS) && rejectedBranch.substring(REFS_HEADS.length()).equals(branchName))) {
return true;
}
}
return false;
}
// if all repos succeeded, show notification.
// if all repos failed, show notification.
// if some repos failed, show notification with both results.
// if in failed repos, some branches were rejected, it is a warning-type, but not an error.
// if in a failed repo, current branch was rejected, propose to update the branch. Don't do it if not current branch was rejected,
// since it is difficult to update such a branch.
// if in a failed repo, a branch was rejected that had nothing to push, don't notify about the rejection.
// Besides all of the above, don't confuse users with 1 repository with all this "repository/root" stuff;
// don't confuse users which push only a single branch with all this "branch" stuff.
private void handleResult(@NotNull GitPushInfo pushInfo, @NotNull GitPushResult result, @Nullable GitPushResult previousResult, @Nullable UpdateSettings updateSettings,
int pushAttempt) {
result.mergeFrom(previousResult);
if (result.isEmpty()) {
VcsNotifier.getInstance(myProject).notifyInfo("Nothing to push");
}
else if (result.wasErrorCancelOrNotAuthorized()) {
// if there was an error on any repo, we won't propose to update even if current branch of a repo was rejected
result.createPushNotificationAndNotify();
}
else {
// there were no errors, but there might be some rejected branches on some of the repositories
// => for current branch propose to update and re-push it. For others just warn
Map<GitRepository, GitBranch> rejectedPushesForCurrentBranch = result.getRejectedPushesFromCurrentBranchToTrackedBranch(pushInfo);
if (pushAttempt <= MAX_PUSH_ATTEMPTS && !rejectedPushesForCurrentBranch.isEmpty()) {
LOG.info(
String.format("Rejected pushes for current branches: %n%s%nUpdate settings: %s", rejectedPushesForCurrentBranch, updateSettings));
if (updateSettings == null) {
// show dialog only when push is rejected for the first time in a row, otherwise reuse previously chosen update method
// and don't show the dialog again if user has chosen not to ask again
updateSettings = readUpdateSettings();
if (!mySettings.autoUpdateIfPushRejected()) {
updateSettings = showDialogAndGetExitCode(rejectedPushesForCurrentBranch, updateSettings);
saveUpdateSettings(updateSettings);
}
}
if (updateSettings.shouldUpdate()) {
Collection<GitRepository> repositoriesToUpdate = getRootsToUpdate(rejectedPushesForCurrentBranch, updateSettings.shouldUpdateAllRoots());
GitPushResult adjustedPushResult = result.remove(rejectedPushesForCurrentBranch);
adjustedPushResult.markUpdateStartIfNotMarked(repositoriesToUpdate);
boolean updateResult = update(repositoriesToUpdate, updateSettings.getUpdateMethod());
if (updateResult) {
myProgressIndicator.setText(INDICATOR_TEXT);
GitPushInfo newPushInfo = pushInfo.retain(rejectedPushesForCurrentBranch);
push(newPushInfo, adjustedPushResult, updateSettings, pushAttempt + 1);
return; // don't notify - next push will notify all results in compound
}
}
}
result.createPushNotificationAndNotify();
}
}
private void saveUpdateSettings(@NotNull UpdateSettings updateSettings) {
UpdateMethod updateMethod = updateSettings.getUpdateMethod();
if (updateMethod != null) { // null if user has pressed cancel
myPushSettings.setUpdateAllRoots(updateSettings.shouldUpdateAllRoots());
myPushSettings.setUpdateMethod(updateMethod);
}
}
@NotNull
private UpdateSettings readUpdateSettings() {
boolean updateAllRoots = myPushSettings.shouldUpdateAllRoots();
UpdateMethod updateMethod = myPushSettings.getUpdateMethod();
return new UpdateSettings(updateAllRoots, updateMethod);
}
private UpdateSettings showDialogAndGetExitCode(final Map<GitRepository, GitBranch> rejectedPushesForCurrentBranch,
final UpdateSettings initialSettings) {
final AtomicReference<UpdateSettings> updateSettings = new AtomicReference<UpdateSettings>();
UIUtil.invokeAndWaitIfNeeded(new Runnable() {
@Override
public void run() {
final GitRejectedPushUpdateDialog dialog = new GitRejectedPushUpdateDialog(myProject, rejectedPushesForCurrentBranch.keySet(), initialSettings);
dialog.show();
final int exitCode = dialog.getExitCode();
if (exitCode != DialogWrapper.CANCEL_EXIT_CODE) {
mySettings.setAutoUpdateIfPushRejected(dialog.shouldAutoUpdateInFuture());
}
updateSettings.set(new UpdateSettings(dialog.shouldUpdateAll(), getUpdateMethodFromDialogExitCode(exitCode)));
}
});
return updateSettings.get();
}
/**
* @return update method selected in the dialog or {@code null} if user pressed Cancel, i.e. doesn't want to update.
*/
@Nullable
private static UpdateMethod getUpdateMethodFromDialogExitCode(int exitCode) {
switch (exitCode) {
case GitRejectedPushUpdateDialog.MERGE_EXIT_CODE: return UpdateMethod.MERGE;
case GitRejectedPushUpdateDialog.REBASE_EXIT_CODE: return UpdateMethod.REBASE;
}
return null;
}
@NotNull
private Collection<GitRepository> getRootsToUpdate(@NotNull Map<GitRepository, GitBranch> rejectedPushesForCurrentBranch, boolean updateAllRoots) {
return updateAllRoots ? myRepositories : rejectedPushesForCurrentBranch.keySet();
}
private boolean update(@NotNull Collection<GitRepository> rootsToUpdate, @NotNull UpdateMethod updateMethod) {
GitUpdateProcess.UpdateMethod um = updateMethod == UpdateMethod.MERGE ? GitUpdateProcess.UpdateMethod.MERGE : GitUpdateProcess.UpdateMethod.REBASE;
GitUpdateResult updateResult = new GitUpdateProcess(myProject, myPlatformFacade, myProgressIndicator,
new HashSet<GitRepository>(rootsToUpdate), UpdatedFiles.create()).update(um);
for (GitRepository repository : rootsToUpdate) {
repository.getRoot().refresh(true, true);
}
if (updateResult == GitUpdateResult.SUCCESS) {
return true;
}
else if (updateResult == GitUpdateResult.SUCCESS_WITH_RESOLVED_CONFLICTS || updateResult == GitUpdateResult.INCOMPLETE) {
String title = "Push cancelled";
String description;
if (updateResult == GitUpdateResult.INCOMPLETE) {
description = "Push has been cancelled, because not all conflicts were resolved during update.<br/>" +
"Resolve the conflicts and invoke push again.";
}
else {
description = "Push has been cancelled, because there were conflicts during update.<br/>" +
"Check that conflicts were resolved correctly, and invoke push again.";
}
VcsNotifier.getInstance(myProject).notifyMinorWarning(title, description);
return false;
}
else {
return false;
}
}
}