blob: 448dd25ef7abfdb16bf2c4822becec367807b0a4 [file] [log] [blame]
/*
* Copyright 2000-2009 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.checkin;
import com.intellij.CommonBundle;
import com.intellij.dvcs.DvcsCommitAdditionalComponent;
import com.intellij.dvcs.DvcsUtil;
import com.intellij.openapi.components.ServiceManager;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.editor.ex.EditorEx;
import com.intellij.openapi.fileTypes.FileTypes;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.ui.ComboBox;
import com.intellij.openapi.util.Ref;
import com.intellij.openapi.util.io.FileUtil;
import com.intellij.openapi.util.text.StringUtil;
import com.intellij.openapi.vcs.CheckinProjectPanel;
import com.intellij.openapi.vcs.FilePath;
import com.intellij.openapi.vcs.VcsException;
import com.intellij.openapi.vcs.changes.*;
import com.intellij.openapi.vcs.changes.ui.SelectFilePathsDialog;
import com.intellij.openapi.vcs.checkin.CheckinChangeListSpecificComponent;
import com.intellij.openapi.vcs.checkin.CheckinEnvironment;
import com.intellij.openapi.vcs.ui.RefreshableOnComponent;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.spellchecker.ui.SpellCheckingEditorCustomization;
import com.intellij.ui.GuiUtils;
import com.intellij.ui.StringComboboxEditor;
import com.intellij.util.*;
import com.intellij.util.containers.ContainerUtil;
import com.intellij.util.ui.UIUtil;
import com.intellij.vcs.log.VcsFullCommitDetails;
import com.intellij.vcs.log.VcsUser;
import com.intellij.vcs.log.VcsUserRegistry;
import com.intellij.vcsUtil.VcsFileUtil;
import com.intellij.vcsUtil.VcsUtil;
import git4idea.GitPlatformFacade;
import git4idea.GitUtil;
import git4idea.GitVcs;
import git4idea.commands.GitCommand;
import git4idea.commands.GitSimpleHandler;
import git4idea.config.GitConfigUtil;
import git4idea.config.GitVcsSettings;
import git4idea.config.GitVersionSpecialty;
import git4idea.i18n.GitBundle;
import git4idea.push.GitPusher;
import git4idea.repo.GitRepositoryFiles;
import git4idea.repo.GitRepositoryManager;
import git4idea.util.GitFileUtils;
import org.jetbrains.annotations.NonNls;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import javax.swing.*;
import java.awt.*;
import java.io.*;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.List;
public class GitCheckinEnvironment implements CheckinEnvironment {
private static final Logger log = Logger.getInstance(GitCheckinEnvironment.class.getName());
@NonNls private static final String GIT_COMMIT_MSG_FILE_PREFIX = "git-commit-msg-"; // the file name prefix for commit message file
@NonNls private static final String GIT_COMMIT_MSG_FILE_EXT = ".txt"; // the file extension for commit message file
private final Project myProject;
public static final SimpleDateFormat COMMIT_DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
private final VcsDirtyScopeManager myDirtyScopeManager;
private final GitVcsSettings mySettings;
private String myNextCommitAuthor = null; // The author for the next commit
private boolean myNextCommitAmend; // If true, the next commit is amended
private Boolean myNextCommitIsPushed = null; // The push option of the next commit
private Date myNextCommitAuthorDate;
public GitCheckinEnvironment(@NotNull Project project, @NotNull final VcsDirtyScopeManager dirtyScopeManager, final GitVcsSettings settings) {
myProject = project;
myDirtyScopeManager = dirtyScopeManager;
mySettings = settings;
}
public boolean keepChangeListAfterCommit(ChangeList changeList) {
return false;
}
@Override
public boolean isRefreshAfterCommitNeeded() {
return false;
}
@Nullable
public RefreshableOnComponent createAdditionalOptionsPanel(CheckinProjectPanel panel,
PairConsumer<Object, Object> additionalDataConsumer) {
return new GitCheckinOptions(myProject, panel);
}
@Nullable
public String getDefaultMessageFor(FilePath[] filesToCheckin) {
LinkedHashSet<String> messages = ContainerUtil.newLinkedHashSet();
for (VirtualFile root : GitUtil.gitRoots(Arrays.asList(filesToCheckin))) {
VirtualFile mergeMsg = root.findFileByRelativePath(GitRepositoryFiles.GIT_MERGE_MSG);
VirtualFile squashMsg = root.findFileByRelativePath(GitRepositoryFiles.GIT_SQUASH_MSG);
try {
if (mergeMsg == null && squashMsg == null) {
continue;
}
String encoding = GitConfigUtil.getCommitEncoding(myProject, root);
if (mergeMsg != null) {
messages.add(loadMessage(mergeMsg, encoding));
}
else {
messages.add(loadMessage(squashMsg, encoding));
}
}
catch (IOException e) {
if (log.isDebugEnabled()) {
log.debug("Unable to load merge message", e);
}
}
}
return DvcsUtil.joinMessagesOrNull(messages);
}
private static String loadMessage(@NotNull VirtualFile messageFile, @NotNull String encoding) throws IOException {
return FileUtil.loadFile(new File(messageFile.getPath()), encoding);
}
public String getHelpId() {
return null;
}
public String getCheckinOperationName() {
return GitBundle.getString("commit.action.name");
}
public List<VcsException> commit(@NotNull List<Change> changes,
@NotNull String message,
@NotNull NullableFunction<Object, Object> parametersHolder, Set<String> feedback) {
List<VcsException> exceptions = new ArrayList<VcsException>();
Map<VirtualFile, Collection<Change>> sortedChanges = sortChangesByGitRoot(changes, exceptions);
log.assertTrue(!sortedChanges.isEmpty(), "Trying to commit an empty list of changes: " + changes);
for (Map.Entry<VirtualFile, Collection<Change>> entry : sortedChanges.entrySet()) {
final VirtualFile root = entry.getKey();
try {
File messageFile = createMessageFile(root, message);
try {
final Set<FilePath> added = new HashSet<FilePath>();
final Set<FilePath> removed = new HashSet<FilePath>();
for (Change change : entry.getValue()) {
switch (change.getType()) {
case NEW:
case MODIFICATION:
added.add(change.getAfterRevision().getFile());
break;
case DELETED:
removed.add(change.getBeforeRevision().getFile());
break;
case MOVED:
FilePath afterPath = change.getAfterRevision().getFile();
FilePath beforePath = change.getBeforeRevision().getFile();
added.add(afterPath);
if (!GitFileUtils.shouldIgnoreCaseChange(afterPath.getPath(), beforePath.getPath())) {
removed.add(beforePath);
}
break;
default:
throw new IllegalStateException("Unknown change type: " + change.getType());
}
}
try {
try {
Set<FilePath> files = new HashSet<FilePath>();
files.addAll(added);
files.addAll(removed);
commit(myProject, root, files, messageFile, myNextCommitAuthor, myNextCommitAmend, myNextCommitAuthorDate);
}
catch (VcsException ex) {
PartialOperation partialOperation = isMergeCommit(ex);
if (partialOperation == PartialOperation.NONE) {
throw ex;
}
if (!mergeCommit(myProject, root, added, removed, messageFile, myNextCommitAuthor, exceptions, partialOperation)) {
throw ex;
}
}
}
finally {
if (!messageFile.delete()) {
log.warn("Failed to remove temporary file: " + messageFile);
}
}
}
catch (VcsException e) {
exceptions.add(cleanupExceptionText(e));
}
}
catch (IOException ex) {
//noinspection ThrowableInstanceNeverThrown
exceptions.add(new VcsException("Creation of commit message file failed", ex));
}
}
if (myNextCommitIsPushed != null && myNextCommitIsPushed.booleanValue() && exceptions.isEmpty()) {
// push
UIUtil.invokeLaterIfNeeded(new Runnable() {
public void run() {
GitPusher.showPushDialogAndPerformPush(myProject, ServiceManager.getService(myProject, GitPlatformFacade.class));
}
});
}
return exceptions;
}
@NotNull
private static VcsException cleanupExceptionText(VcsException original) {
String msg = original.getMessage();
msg = GitUtil.cleanupErrorPrefixes(msg);
final String DURING_EXECUTING_SUFFIX = GitSimpleHandler.DURING_EXECUTING_ERROR_MESSAGE;
int suffix = msg.indexOf(DURING_EXECUTING_SUFFIX);
if (suffix > 0) {
msg = msg.substring(0, suffix);
}
return new VcsException(msg.trim(), original.getCause());
}
public List<VcsException> commit(List<Change> changes, String preparedComment) {
return commit(changes, preparedComment, FunctionUtil.nullConstant(), null);
}
/**
* Preform a merge commit
*
*
* @param project a project
* @param root a vcs root
* @param added added files
* @param removed removed files
* @param messageFile a message file for commit
* @param author an author
* @param exceptions the list of exceptions to report
* @param partialOperation
* @return true if merge commit was successful
*/
private static boolean mergeCommit(final Project project,
final VirtualFile root,
final Set<FilePath> added,
final Set<FilePath> removed,
final File messageFile,
final String author,
List<VcsException> exceptions, @NotNull final PartialOperation partialOperation) {
HashSet<FilePath> realAdded = new HashSet<FilePath>();
HashSet<FilePath> realRemoved = new HashSet<FilePath>();
// perform diff
GitSimpleHandler diff = new GitSimpleHandler(project, root, GitCommand.DIFF);
diff.setSilent(true);
diff.setStdoutSuppressed(true);
diff.addParameters("--diff-filter=ADMRUX", "--name-status", "HEAD");
diff.endOptions();
String output;
try {
output = diff.run();
}
catch (VcsException ex) {
exceptions.add(ex);
return false;
}
String rootPath = root.getPath();
for (StringTokenizer lines = new StringTokenizer(output, "\n", false); lines.hasMoreTokens();) {
String line = lines.nextToken().trim();
if (line.length() == 0) {
continue;
}
String[] tk = line.split("\t");
switch (tk[0].charAt(0)) {
case 'M':
case 'A':
realAdded.add(VcsUtil.getFilePath(rootPath + "/" + tk[1]));
break;
case 'D':
realRemoved.add(VcsUtil.getFilePathForDeletedFile(rootPath + "/" + tk[1], false));
break;
default:
throw new IllegalStateException("Unexpected status: " + line);
}
}
realAdded.removeAll(added);
realRemoved.removeAll(removed);
if (realAdded.size() != 0 || realRemoved.size() != 0) {
final List<FilePath> files = new ArrayList<FilePath>();
files.addAll(realAdded);
files.addAll(realRemoved);
final Ref<Boolean> mergeAll = new Ref<Boolean>();
try {
GuiUtils.runOrInvokeAndWait(new Runnable() {
public void run() {
String message = GitBundle.message("commit.partial.merge.message", partialOperation.getName());
SelectFilePathsDialog dialog = new SelectFilePathsDialog(project, files, message,
null, "Commit All Files", CommonBundle.getCancelButtonText(), false);
dialog.setTitle(GitBundle.getString("commit.partial.merge.title"));
dialog.show();
mergeAll.set(dialog.isOK());
}
});
}
catch (RuntimeException ex) {
throw ex;
}
catch (Exception ex) {
throw new RuntimeException("Unable to invoke a message box on AWT thread", ex);
}
if (!mergeAll.get()) {
return false;
}
// update non-indexed files
if (!updateIndex(project, root, realAdded, realRemoved, exceptions)) {
return false;
}
for (FilePath f : realAdded) {
VcsDirtyScopeManager.getInstance(project).fileDirty(f);
}
for (FilePath f : realRemoved) {
VcsDirtyScopeManager.getInstance(project).fileDirty(f);
}
}
// perform merge commit
try {
GitSimpleHandler handler = new GitSimpleHandler(project, root, GitCommand.COMMIT);
handler.setStdoutSuppressed(false);
handler.addParameters("-F", messageFile.getAbsolutePath());
if (author != null) {
handler.addParameters("--author=" + author);
}
handler.endOptions();
handler.run();
GitRepositoryManager manager = GitUtil.getRepositoryManager(project);
manager.updateRepository(root);
}
catch (VcsException ex) {
exceptions.add(ex);
return false;
}
return true;
}
/**
* Check if commit has failed due to unfinished merge or cherry-pick.
*
*
* @param ex an exception to examine
* @return true if exception means that there is a partial commit during merge
*/
private static PartialOperation isMergeCommit(final VcsException ex) {
String message = ex.getMessage();
if (message.contains("fatal: cannot do a partial commit during a merge")) {
return PartialOperation.MERGE;
}
if (message.contains("fatal: cannot do a partial commit during a cherry-pick")) {
return PartialOperation.CHERRY_PICK;
}
return PartialOperation.NONE;
}
/**
* Update index (delete and remove files)
*
* @param project the project
* @param root a vcs root
* @param added added/modified files to commit
* @param removed removed files to commit
* @param exceptions a list of exceptions to update
* @return true if index was updated successfully
*/
private static boolean updateIndex(final Project project,
final VirtualFile root,
final Collection<FilePath> added,
final Collection<FilePath> removed,
final List<VcsException> exceptions) {
boolean rc = true;
if (!added.isEmpty()) {
try {
GitFileUtils.addPaths(project, root, added);
}
catch (VcsException ex) {
exceptions.add(ex);
rc = false;
}
}
if (!removed.isEmpty()) {
try {
GitFileUtils.delete(project, root, removed, "--ignore-unmatch");
}
catch (VcsException ex) {
exceptions.add(ex);
rc = false;
}
}
return rc;
}
/**
* Create a file that contains the specified message
*
* @param root a git repository root
* @param message a message to write
* @return a file reference
* @throws IOException if file cannot be created
*/
private File createMessageFile(VirtualFile root, final String message) throws IOException {
// filter comment lines
File file = FileUtil.createTempFile(GIT_COMMIT_MSG_FILE_PREFIX, GIT_COMMIT_MSG_FILE_EXT);
file.deleteOnExit();
@NonNls String encoding = GitConfigUtil.getCommitEncoding(myProject, root);
Writer out = new OutputStreamWriter(new FileOutputStream(file), encoding);
try {
out.write(message);
}
finally {
out.close();
}
return file;
}
public List<VcsException> scheduleMissingFileForDeletion(List<FilePath> files) {
ArrayList<VcsException> rc = new ArrayList<VcsException>();
Map<VirtualFile, List<FilePath>> sortedFiles;
try {
sortedFiles = GitUtil.sortFilePathsByGitRoot(files);
}
catch (VcsException e) {
rc.add(e);
return rc;
}
for (Map.Entry<VirtualFile, List<FilePath>> e : sortedFiles.entrySet()) {
try {
final VirtualFile root = e.getKey();
GitFileUtils.delete(myProject, root, e.getValue());
markRootDirty(root);
}
catch (VcsException ex) {
rc.add(ex);
}
}
return rc;
}
private static void commit(Project project,
VirtualFile root,
Collection<FilePath> files,
File message,
final String nextCommitAuthor,
boolean nextCommitAmend, Date nextCommitAuthorDate)
throws VcsException {
boolean amend = nextCommitAmend;
for (List<String> paths : VcsFileUtil.chunkPaths(root, files)) {
GitSimpleHandler handler = new GitSimpleHandler(project, root, GitCommand.COMMIT);
handler.setStdoutSuppressed(false);
if (amend) {
handler.addParameters("--amend");
}
else {
amend = true;
}
handler.addParameters("--only", "-F", message.getAbsolutePath());
if (nextCommitAuthor != null) {
handler.addParameters("--author=" + nextCommitAuthor);
}
if (nextCommitAuthorDate != null) {
handler.addParameters("--date", COMMIT_DATE_FORMAT.format(nextCommitAuthorDate));
}
handler.endOptions();
handler.addParameters(paths);
handler.run();
}
if (!project.isDisposed()) {
GitRepositoryManager manager = GitUtil.getRepositoryManager(project);
manager.updateRepository(root);
}
}
public List<VcsException> scheduleUnversionedFilesForAddition(List<VirtualFile> files) {
ArrayList<VcsException> rc = new ArrayList<VcsException>();
Map<VirtualFile, List<VirtualFile>> sortedFiles;
try {
sortedFiles = GitUtil.sortFilesByGitRoot(files);
}
catch (VcsException e) {
rc.add(e);
return rc;
}
for (Map.Entry<VirtualFile, List<VirtualFile>> e : sortedFiles.entrySet()) {
try {
final VirtualFile root = e.getKey();
GitFileUtils.addFiles(myProject, root, e.getValue());
markRootDirty(root);
}
catch (VcsException ex) {
rc.add(ex);
}
}
return rc;
}
private enum PartialOperation {
NONE("none"),
MERGE("merge"),
CHERRY_PICK("cherry-pick");
private final String myName;
PartialOperation(String name) {
myName = name;
}
String getName() {
return myName;
}
}
private static Map<VirtualFile, Collection<Change>> sortChangesByGitRoot(@NotNull List<Change> changes, List<VcsException> exceptions) {
Map<VirtualFile, Collection<Change>> result = new HashMap<VirtualFile, Collection<Change>>();
for (Change change : changes) {
final ContentRevision afterRevision = change.getAfterRevision();
final ContentRevision beforeRevision = change.getBeforeRevision();
// nothing-to-nothing change cannot happen.
assert beforeRevision != null || afterRevision != null;
// note that any path will work, because changes could happen within single vcs root
final FilePath filePath = afterRevision != null ? afterRevision.getFile() : beforeRevision.getFile();
final VirtualFile vcsRoot;
try {
// the parent paths for calculating roots in order to account for submodules that contribute
// to the parent change. The path "." is never is valid change, so there should be no problem
// with it.
vcsRoot = GitUtil.getGitRoot(filePath.getParentPath());
}
catch (VcsException e) {
exceptions.add(e);
continue;
}
Collection<Change> changeList = result.get(vcsRoot);
if (changeList == null) {
changeList = new ArrayList<Change>();
result.put(vcsRoot, changeList);
}
changeList.add(change);
}
return result;
}
private void markRootDirty(final VirtualFile root) {
// Note that the root is invalidated because changes are detected per-root anyway.
// Otherwise it is not possible to detect moves.
myDirtyScopeManager.dirDirtyRecursively(root);
}
public void reset() {
myNextCommitAmend = false;
myNextCommitAuthor = null;
myNextCommitIsPushed = null;
myNextCommitAuthorDate = null;
}
private class GitCheckinOptions extends DvcsCommitAdditionalComponent implements CheckinChangeListSpecificComponent {
private final GitVcs myVcs;
private final ComboBox myAuthor;
private Date myAuthorDate;
GitCheckinOptions(@NotNull final Project project, @NotNull CheckinProjectPanel panel) {
super(project, panel);
myVcs = GitVcs.getInstance(project);
final Insets insets = new Insets(2, 2, 2, 2);
// add authors drop down
GridBagConstraints c = new GridBagConstraints();
c.gridx = 0;
c.gridy = 0;
c.anchor = GridBagConstraints.WEST;
c.insets = insets;
final JLabel authorLabel = new JLabel(GitBundle.message("commit.author"));
myPanel.add(authorLabel, c);
c = new GridBagConstraints();
c.anchor = GridBagConstraints.CENTER;
c.insets = insets;
c.gridx = 1;
c.gridy = 0;
c.weightx = 1;
c.fill = GridBagConstraints.HORIZONTAL;
Set<String> authors = new HashSet<String>(getUsersList(project));
ContainerUtil.addAll(authors, mySettings.getCommitAuthors());
List<String> list = new ArrayList<String>(authors);
Collections.sort(list);
myAuthor = new ComboBox(ArrayUtil.toObjectArray(list)) {
@Override
public void addNotify() {
super.addNotify();
// adding in addNotify to make sure the editor is ready for further customization
StringComboboxEditor comboboxEditor = new StringComboboxEditor(project, FileTypes.PLAIN_TEXT, myAuthor, true);
myAuthor.setEditor(comboboxEditor);
EditorEx editor = (EditorEx)comboboxEditor.getEditor();
assert editor != null;
SpellCheckingEditorCustomization.getInstance(false).customize(editor);
}
};
myAuthor.setMinimumAndPreferredWidth(100);
myAuthor.insertItemAt("", 0);
myAuthor.setSelectedItem("");
myAuthor.setEditable(true);
authorLabel.setLabelFor(myAuthor);
myAuthor.setToolTipText(GitBundle.getString("commit.author.tooltip"));
myPanel.add(myAuthor, c);
}
@Override
@NotNull
protected Set<VirtualFile> getVcsRoots(@NotNull Collection<FilePath> filePaths) {
return GitUtil.gitRoots(filePaths);
}
@Nullable
@Override
protected String getLastCommitMessage(@NotNull VirtualFile root) throws VcsException {
GitSimpleHandler h = new GitSimpleHandler(myProject, root, GitCommand.LOG);
h.addParameters("--max-count=1");
String formatPattern;
if (GitVersionSpecialty.STARTED_USING_RAW_BODY_IN_FORMAT.existsIn(myVcs.getVersion())) {
formatPattern = "%B";
}
else {
// only message: subject + body; "%-b" means that preceding line-feeds will be deleted if the body is empty
// %s strips newlines from subject; there is no way to work around it before 1.7.2 with %B (unless parsing some fixed format)
formatPattern = "%s%n%n%-b";
}
h.addParameters("--pretty=format:" + formatPattern);
return h.run();
}
@NotNull
private List<String> getUsersList(@NotNull Project project) {
VcsUserRegistry userRegistry = ServiceManager.getService(project, VcsUserRegistry.class);
return ContainerUtil.map(userRegistry.getUsers(), new Function<VcsUser, String>() {
@Override
public String fun(VcsUser user) {
return user.getName() + " <" + user.getEmail() + ">";
}
});
}
@Override
public void refresh() {
super.refresh();
myAuthor.setSelectedItem("");
reset();
}
@Override
public void saveState() {
String author = (String)myAuthor.getEditor().getItem();
if (StringUtil.isEmptyOrSpaces(author)) {
myNextCommitAuthor = null;
}
else {
myNextCommitAuthor = GitCommitAuthorCorrector.correct(author);
mySettings.saveCommitAuthor(myNextCommitAuthor);
}
myNextCommitAmend = myAmend.isSelected();
myNextCommitAuthorDate = myAuthorDate;
}
@Override
public void restoreState() {
refresh();
}
@Override
public void onChangeListSelected(LocalChangeList list) {
Object data = list.getData();
if (data instanceof VcsFullCommitDetails) {
VcsFullCommitDetails commit = (VcsFullCommitDetails)data;
String author = String.format("%s <%s>", commit.getAuthor().getName(), commit.getAuthor().getEmail());
myAuthor.getEditor().setItem(author);
myAuthorDate = new Date(commit.getTimestamp());
}
}
}
public void setNextCommitIsPushed(Boolean nextCommitIsPushed) {
myNextCommitIsPushed = nextCommitIsPushed;
}
}