4bb72d90cb3a767271d70897cf44caa3d36c58f7
[idea/community.git] / plugins / git4idea / src / git4idea / checkin / GitCheckinEnvironment.java
1 // Copyright 2000-2020 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
2 package git4idea.checkin;
3
4 import com.google.common.collect.HashMultiset;
5 import com.intellij.CommonBundle;
6 import com.intellij.diff.util.Side;
7 import com.intellij.dvcs.DvcsUtil;
8 import com.intellij.openapi.Disposable;
9 import com.intellij.openapi.application.ApplicationManager;
10 import com.intellij.openapi.application.ModalityState;
11 import com.intellij.openapi.application.TransactionGuard;
12 import com.intellij.openapi.diagnostic.Logger;
13 import com.intellij.openapi.fileEditor.FileDocumentManager;
14 import com.intellij.openapi.fileEditor.impl.LoadTextUtil;
15 import com.intellij.openapi.project.Project;
16 import com.intellij.openapi.util.*;
17 import com.intellij.openapi.util.io.FileUtil;
18 import com.intellij.openapi.util.registry.Registry;
19 import com.intellij.openapi.util.text.StringUtil;
20 import com.intellij.openapi.vcs.CheckinProjectPanel;
21 import com.intellij.openapi.vcs.FilePath;
22 import com.intellij.openapi.vcs.VcsException;
23 import com.intellij.openapi.vcs.VcsRoot;
24 import com.intellij.openapi.vcs.changes.*;
25 import com.intellij.openapi.vcs.changes.ui.SelectFilePathsDialog;
26 import com.intellij.openapi.vcs.checkin.CheckinChangeListSpecificComponent;
27 import com.intellij.openapi.vcs.checkin.CheckinEnvironment;
28 import com.intellij.openapi.vcs.ex.PartialCommitHelper;
29 import com.intellij.openapi.vcs.ex.PartialLocalLineStatusTracker;
30 import com.intellij.openapi.vcs.history.VcsRevisionNumber;
31 import com.intellij.openapi.vcs.impl.LineStatusTrackerManager;
32 import com.intellij.openapi.vcs.impl.PartialChangesUtil;
33 import com.intellij.openapi.vcs.ui.RefreshableOnComponent;
34 import com.intellij.openapi.vfs.VirtualFile;
35 import com.intellij.ui.GuiUtils;
36 import com.intellij.util.ArrayUtil;
37 import com.intellij.util.PairConsumer;
38 import com.intellij.util.ThrowableConsumer;
39 import com.intellij.util.concurrency.FutureResult;
40 import com.intellij.vcs.commit.AmendCommitAware;
41 import com.intellij.vcs.commit.EditedCommitDetails;
42 import com.intellij.vcs.log.Hash;
43 import com.intellij.vcs.log.VcsUser;
44 import com.intellij.vcs.log.impl.HashImpl;
45 import com.intellij.vcsUtil.VcsFileUtil;
46 import com.intellij.vcsUtil.VcsUtil;
47 import git4idea.GitUtil;
48 import git4idea.GitVcs;
49 import git4idea.branch.GitBranchUtil;
50 import git4idea.changes.GitChangeUtils;
51 import git4idea.changes.GitChangeUtils.GitDiffChange;
52 import git4idea.checkin.GitCheckinExplicitMovementProvider.Movement;
53 import git4idea.commands.Git;
54 import git4idea.commands.GitCommand;
55 import git4idea.commands.GitLineHandler;
56 import git4idea.config.GitConfigUtil;
57 import git4idea.i18n.GitBundle;
58 import git4idea.index.GitIndexUtil;
59 import git4idea.repo.GitRepository;
60 import git4idea.repo.GitRepositoryManager;
61 import git4idea.util.GitFileUtils;
62 import gnu.trove.THashSet;
63 import org.jetbrains.annotations.Nls;
64 import org.jetbrains.annotations.NonNls;
65 import org.jetbrains.annotations.NotNull;
66 import org.jetbrains.annotations.Nullable;
67 import org.jetbrains.concurrency.CancellablePromise;
68
69 import javax.swing.*;
70 import java.io.*;
71 import java.text.SimpleDateFormat;
72 import java.util.*;
73 import java.util.concurrent.ExecutionException;
74
75 import static com.intellij.dvcs.DvcsUtil.getShortRepositoryName;
76 import static com.intellij.openapi.vcs.changes.ChangesUtil.*;
77 import static com.intellij.util.containers.ContainerUtil.*;
78 import static com.intellij.vcs.commit.AbstractCommitWorkflowKt.isAmendCommitMode;
79 import static com.intellij.vcs.commit.LocalChangesCommitterKt.getCommitWithoutChangesRoots;
80 import static com.intellij.vcs.commit.ToggleAmendCommitOption.isAmendCommitOptionSupported;
81 import static git4idea.GitUtil.*;
82 import static git4idea.checkin.GitCommitAndPushExecutorKt.isPushAfterCommit;
83 import static git4idea.checkin.GitCommitOptionsKt.*;
84 import static git4idea.checkin.GitSkipHooksCommitHandlerFactoryKt.isSkipHooks;
85 import static git4idea.repo.GitSubmoduleKt.isSubmodule;
86
87 public class GitCheckinEnvironment implements CheckinEnvironment, AmendCommitAware {
88   private static final Logger LOG = Logger.getInstance(GitCheckinEnvironment.class);
89   @NonNls private static final String GIT_COMMIT_MSG_FILE_PREFIX = "git-commit-msg-"; // the file name prefix for commit message file
90   @NonNls private static final String GIT_COMMIT_MSG_FILE_EXT = ".txt"; // the file extension for commit message file
91
92   private final Project myProject;
93   public static final SimpleDateFormat COMMIT_DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
94
95   private VcsUser myNextCommitAuthor; // The author for the next commit
96   private boolean myNextCommitAmend; // If true, the next commit is amended
97   private Date myNextCommitAuthorDate;
98   private boolean myNextCommitSignOff;
99   private boolean myNextCommitSkipHook;
100
101   public GitCheckinEnvironment(@NotNull Project project) {
102     myProject = project;
103   }
104
105   @Override
106   public boolean isRefreshAfterCommitNeeded() {
107     return true;
108   }
109
110   @NotNull
111   @Override
112   public RefreshableOnComponent createCommitOptions(@NotNull CheckinProjectPanel commitPanel, @NotNull CommitContext commitContext) {
113     return new GitCheckinOptions(commitPanel, commitContext, isAmendCommitOptionSupported(commitPanel, this));
114   }
115
116   @Override
117   @Nullable
118   public String getDefaultMessageFor(FilePath @NotNull [] filesToCheckin) {
119     LinkedHashSet<String> messages = new LinkedHashSet<>();
120     GitRepositoryManager manager = getRepositoryManager(myProject);
121     Set<GitRepository> repositories = map2SetNotNull(Arrays.asList(filesToCheckin), manager::getRepositoryForFileQuick);
122     for (GitRepository repository : repositories) {
123       File mergeMsg = repository.getRepositoryFiles().getMergeMessageFile();
124       File squashMsg = repository.getRepositoryFiles().getSquashMessageFile();
125       try {
126         if (!mergeMsg.exists() && !squashMsg.exists()) {
127           continue;
128         }
129         String encoding = GitConfigUtil.getCommitEncoding(myProject, repository.getRoot());
130         if (mergeMsg.exists()) {
131           messages.add(loadMessage(mergeMsg, encoding));
132         }
133         else {
134           messages.add(loadMessage(squashMsg, encoding));
135         }
136       }
137       catch (IOException e) {
138         if (LOG.isDebugEnabled()) {
139           LOG.debug("Unable to load merge message", e);
140         }
141       }
142     }
143     return DvcsUtil.joinMessagesOrNull(messages);
144   }
145
146   private static String loadMessage(@NotNull File messageFile, @NotNull @NonNls String encoding) throws IOException {
147     return FileUtil.loadFile(messageFile, encoding);
148   }
149
150   @Override
151   public String getHelpId() {
152     return null;
153   }
154
155   @Override
156   public String getCheckinOperationName() {
157     return GitBundle.getString("commit.action.name");
158   }
159
160   @Override
161   public boolean isAmendCommitSupported() {
162     return getAmendService().isAmendCommitSupported();
163   }
164
165   @Nullable
166   @Override
167   public String getLastCommitMessage(@NotNull VirtualFile root) throws VcsException {
168     return getAmendService().getLastCommitMessage(root);
169   }
170
171   @NotNull
172   @Override
173   public CancellablePromise<EditedCommitDetails> getAmendCommitDetails(@NotNull VirtualFile root) {
174     return getAmendService().getAmendCommitDetails(root);
175   }
176
177   @NotNull
178   private GitAmendCommitService getAmendService() {
179     return myProject.getService(GitAmendCommitService.class);
180   }
181
182   private void updateState(@NotNull CommitContext commitContext) {
183     myNextCommitAmend = isAmendCommitMode(commitContext);
184     myNextCommitSkipHook = isSkipHooks(commitContext);
185     myNextCommitAuthor = getCommitAuthor(commitContext);
186     myNextCommitAuthorDate = getCommitAuthorDate(commitContext);
187     myNextCommitSignOff = isSignOffCommit(commitContext);
188   }
189
190   @NotNull
191   @Override
192   public List<VcsException> commit(@NotNull List<? extends Change> changes,
193                                    @NotNull @NonNls String commitMessage,
194                                    @NotNull CommitContext commitContext,
195                                    @NotNull Set<? super String> feedback) {
196     updateState(commitContext);
197
198     List<VcsException> exceptions = new ArrayList<>();
199     Map<GitRepository, Collection<Change>> sortedChanges = sortChangesByGitRoot(myProject, changes, exceptions);
200     Collection<VcsRoot> commitWithoutChangesRoots = getCommitWithoutChangesRoots(commitContext);
201     LOG.assertTrue(!sortedChanges.isEmpty() || !commitWithoutChangesRoots.isEmpty(),
202                    "Trying to commit an empty list of changes: " + changes);
203
204     List<GitRepository> repositories = collectRepositories(sortedChanges.keySet(), commitWithoutChangesRoots);
205     for (GitRepository repository : repositories) {
206       Collection<Change> rootChanges = sortedChanges.getOrDefault(repository, emptyList());
207       Collection<CommitChange> toCommit = map(rootChanges, CommitChange::new);
208
209       if (isCommitRenamesSeparately(commitContext)) {
210         Pair<Collection<CommitChange>, List<VcsException>> pair = commitExplicitRenames(repository, toCommit, commitMessage);
211         toCommit = pair.first;
212         List<VcsException> moveExceptions = pair.second;
213
214         if (!moveExceptions.isEmpty()) {
215           exceptions.addAll(moveExceptions);
216           continue;
217         }
218       }
219
220       exceptions.addAll(commitRepository(repository, toCommit, commitMessage));
221     }
222
223     if (isPushAfterCommit(commitContext) && exceptions.isEmpty()) {
224       ModalityState modality = ModalityState.defaultModalityState();
225       TransactionGuard.getInstance().assertWriteSafeContext(modality);
226
227       List<GitRepository> preselectedRepositories = new ArrayList<>(repositories);
228       GuiUtils.invokeLaterIfNeeded(
229         () -> new GitPushAfterCommitDialog(myProject, preselectedRepositories,
230                                            GitBranchUtil.getCurrentRepository(myProject)).showOrPush(),
231         modality, myProject.getDisposed());
232     }
233     return exceptions;
234   }
235
236   @NotNull
237   private List<GitRepository> collectRepositories(@NotNull Collection<GitRepository> changesRepositories,
238                                                   @NotNull Collection<VcsRoot> noChangesRoots) {
239     GitRepositoryManager repositoryManager = getRepositoryManager(myProject);
240     GitVcs vcs = GitVcs.getInstance(myProject);
241     Collection<GitRepository> noChangesRepositories =
242       getRepositoriesFromRoots(repositoryManager, mapNotNull(noChangesRoots, it -> it.getVcs() == vcs ? it.getPath() : null));
243
244     return repositoryManager.sortByDependency(union(changesRepositories, noChangesRepositories));
245   }
246
247   @NotNull
248   private List<VcsException> commitRepository(@NotNull GitRepository repository,
249                                               @NotNull Collection<? extends CommitChange> changes,
250                                               @NotNull @NonNls String message) {
251     List<VcsException> exceptions = new ArrayList<>();
252     VirtualFile root = repository.getRoot();
253
254     try {
255       // Stage partial changes
256       Pair<Runnable, List<CommitChange>> partialAddResult = addPartialChangesToIndex(repository, changes);
257       Runnable callback = partialAddResult.first;
258       Set<CommitChange> changedWithIndex = new HashSet<>(partialAddResult.second);
259
260       // Stage case-only renames
261       List<CommitChange> caseOnlyRenameChanges = addCaseOnlyRenamesToIndex(repository, changes, changedWithIndex, exceptions);
262       if (!exceptions.isEmpty()) return exceptions;
263       changedWithIndex.addAll(caseOnlyRenameChanges);
264
265       if (!changedWithIndex.isEmpty() || Registry.is("git.force.commit.using.staging.area")) {
266         runWithMessageFile(myProject, root, message, messageFile -> exceptions.addAll(commitUsingIndex(repository, changes, changedWithIndex, messageFile)));
267         if (!exceptions.isEmpty()) return exceptions;
268
269         callback.run();
270       }
271       else {
272         try {
273           runWithMessageFile(myProject, root, message, messageFile -> {
274             List<FilePath> files = getPaths(changes);
275             commit(myProject, root, files, messageFile);
276           });
277         }
278         catch (VcsException ex) {
279           PartialOperation partialOperation = isMergeCommit(ex);
280           if (partialOperation == PartialOperation.NONE) {
281             throw ex;
282           }
283           runWithMessageFile(myProject, root, message, messageFile -> {
284             if (!mergeCommit(myProject, root, changes, messageFile, exceptions, partialOperation)) {
285               throw ex;
286             }
287           });
288         }
289       }
290
291       getRepositoryManager(myProject).updateRepository(root);
292       if (isSubmodule(repository)) {
293         VcsDirtyScopeManager.getInstance(myProject).dirDirtyRecursively(repository.getRoot().getParent());
294       }
295     }
296     catch (VcsException e) {
297       exceptions.add(e);
298     }
299     return exceptions;
300   }
301
302   @NotNull
303   private List<VcsException> commitUsingIndex(@NotNull GitRepository repository,
304                                               @NotNull Collection<? extends CommitChange> rootChanges,
305                                               @NotNull Set<? extends CommitChange> changedWithIndex,
306                                               @NotNull File messageFile) {
307     List<VcsException> exceptions = new ArrayList<>();
308     try {
309       Set<FilePath> added = map2SetNotNull(rootChanges, it -> it.afterPath);
310       Set<FilePath> removed = map2SetNotNull(rootChanges, it -> it.beforePath);
311
312       VirtualFile root = repository.getRoot();
313       String rootPath = root.getPath();
314
315       List<FilePath> unmergedFiles = GitChangeUtils.getUnmergedFiles(repository);
316       if (!unmergedFiles.isEmpty()) {
317         throw new VcsException("Committing is not possible because you have unmerged files.");
318       }
319
320       // Check what is staged besides our changes
321       Collection<GitDiffChange> stagedChanges = GitChangeUtils.getStagedChanges(myProject, root);
322       LOG.debug("Found staged changes: " + getLogStringGitDiffChanges(rootPath, stagedChanges));
323       Collection<ChangedPath> excludedStagedChanges = new ArrayList<>();
324       processExcludedPaths(stagedChanges, added, removed, (before, after) -> {
325         if (before != null || after != null) excludedStagedChanges.add(new ChangedPath(before, after));
326       });
327
328       // Find unstaged deletions, we might not be able to restore them after
329       Collection<GitDiffChange> unstagedChanges = GitChangeUtils.getUnstagedChanges(myProject, root, false);
330       LOG.debug("Found unstaged changes: " + getLogStringGitDiffChanges(rootPath, unstagedChanges));
331       Set<FilePath> excludedUnstagedDeletions = new HashSet<>();
332       processExcludedPaths(unstagedChanges, added, removed, (before, after) -> {
333         if (before != null && after == null) excludedUnstagedDeletions.add(before);
334       });
335
336       if (!excludedStagedChanges.isEmpty()) {
337         // Reset staged changes which are not selected for commit
338         LOG.info("Staged changes excluded for commit: " + getLogString(rootPath, excludedStagedChanges));
339         resetExcluded(myProject, root, excludedStagedChanges);
340       }
341       try {
342         List<FilePath> alreadyHandledPaths = getPaths(changedWithIndex);
343         // Stage what else is needed to commit
344         Set<FilePath> toAdd = new HashSet<>(added);
345         toAdd.removeAll(alreadyHandledPaths);
346
347         Set<FilePath> toRemove = new HashSet<>(removed);
348         toRemove.removeAll(toAdd);
349         toRemove.removeAll(alreadyHandledPaths);
350
351         LOG.debug(String.format("Updating index: added: %s, removed: %s", toAdd, toRemove));
352         updateIndex(myProject, root, toAdd, toRemove, exceptions);
353         if (!exceptions.isEmpty()) return exceptions;
354
355
356         // Commit the staging area
357         LOG.debug("Performing commit...");
358         commitWithoutPaths(myProject, root, messageFile);
359       }
360       finally {
361         // Stage back the changes unstaged before commit
362         if (!excludedStagedChanges.isEmpty()) {
363           restoreExcluded(myProject, root, excludedStagedChanges, excludedUnstagedDeletions);
364         }
365       }
366     }
367     catch (VcsException e) {
368       exceptions.add(e);
369     }
370     return exceptions;
371   }
372
373
374   @NotNull
375   private Pair<Runnable, List<CommitChange>> addPartialChangesToIndex(@NotNull GitRepository repository,
376                                                                       @NotNull Collection<? extends CommitChange> changes) throws VcsException {
377     Set<String> changelistIds = map2SetNotNull(changes, change -> change.changelistId);
378     if (changelistIds.isEmpty()) return Pair.create(EmptyRunnable.INSTANCE, emptyList());
379     if (changelistIds.size() != 1) throw new VcsException("Can't commit changes from multiple changelists at once");
380     String changelistId = changelistIds.iterator().next();
381
382     Pair<List<PartialCommitHelper>, List<CommitChange>> result = computeAfterLSTManagerUpdate(repository.getProject(), () -> {
383       List<PartialCommitHelper> helpers = new ArrayList<>();
384       List<CommitChange> partialChanges = new ArrayList<>();
385
386       for (CommitChange change : changes) {
387         if (change.changelistId != null && change.virtualFile != null &&
388             change.beforePath != null && change.afterPath != null) {
389           PartialLocalLineStatusTracker tracker = PartialChangesUtil.getPartialTracker(myProject, change.virtualFile);
390
391           if (tracker == null) continue;
392           if (!tracker.isOperational()) {
393             LOG.warn("Tracker is not operational for " + tracker.getVirtualFile().getPresentableUrl());
394             return null; // commit failure
395           }
396
397           if (tracker.hasPartialChangesToCommit()) {
398             helpers.add(tracker.handlePartialCommit(Side.LEFT, Collections.singletonList(changelistId), true));
399             partialChanges.add(change);
400           }
401         }
402       }
403
404       return Pair.create(helpers, partialChanges);
405     });
406
407     if (result == null) throw new VcsException("Can't collect partial changes to commit");
408     List<PartialCommitHelper> helpers = result.first;
409     List<CommitChange> partialChanges = result.second;
410
411
412     List<FilePath> pathsToDelete = new ArrayList<>();
413     for (CommitChange change : partialChanges) {
414       if (change.isMove()) {
415         pathsToDelete.add(Objects.requireNonNull(change.beforePath));
416       }
417     }
418     LOG.debug(String.format("Updating index for partial changes: removing: %s", pathsToDelete));
419     GitFileUtils.deletePaths(myProject, repository.getRoot(), pathsToDelete, "--ignore-unmatch");
420
421
422     LOG.debug(String.format("Updating index for partial changes: changes: %s", partialChanges));
423     for (int i = 0; i < partialChanges.size(); i++) {
424       CommitChange change = partialChanges.get(i);
425
426       FilePath path = Objects.requireNonNull(change.afterPath);
427       PartialCommitHelper helper = helpers.get(i);
428       VirtualFile file = change.virtualFile;
429       if (file == null) throw new VcsException("Can't find file: " + path.getPath());
430
431       GitIndexUtil.StagedFile stagedFile = getStagedFile(repository, change);
432       boolean isExecutable = stagedFile != null && stagedFile.isExecutable();
433
434       byte[] fileContent = convertDocumentContentToBytes(repository, helper.getContent(), file);
435
436       byte[] bom = file.getBOM();
437       if (bom != null && !ArrayUtil.startsWith(fileContent, bom)) {
438         fileContent = ArrayUtil.mergeArrays(bom, fileContent);
439       }
440
441       GitIndexUtil.write(repository, path, fileContent, isExecutable);
442     }
443
444
445     Runnable callback = () -> ApplicationManager.getApplication().invokeLater(() -> {
446       for (PartialCommitHelper helper : helpers) {
447         try {
448           helper.applyChanges();
449         }
450         catch (Throwable e) {
451           LOG.error(e);
452         }
453       }
454     });
455
456     return Pair.create(callback, partialChanges);
457   }
458
459   private static byte @NotNull [] convertDocumentContentToBytes(@NotNull GitRepository repository,
460                                                                 @NotNull @NonNls String documentContent,
461                                                                 @NotNull VirtualFile file) {
462     String text;
463
464     String lineSeparator = FileDocumentManager.getInstance().getLineSeparator(file, repository.getProject());
465     if (lineSeparator.equals("\n")) {
466       text = documentContent;
467     }
468     else {
469       text = StringUtil.convertLineSeparators(documentContent, lineSeparator);
470     }
471
472     return LoadTextUtil.charsetForWriting(repository.getProject(), file, text, file.getCharset()).second;
473   }
474
475   @Nullable
476   private static GitIndexUtil.StagedFile getStagedFile(@NotNull GitRepository repository,
477                                                        @NotNull CommitChange change) throws VcsException {
478     FilePath bPath = change.beforePath;
479     if (bPath != null) {
480       GitIndexUtil.StagedFile file = GitIndexUtil.listStaged(repository, bPath);
481       if (file != null) return file;
482     }
483
484     FilePath aPath = change.afterPath;
485     if (aPath != null) {
486       GitIndexUtil.StagedFile file = GitIndexUtil.listStaged(repository, aPath);
487       if (file != null) return file;
488     }
489     return null;
490   }
491
492   @Nullable
493   private static <T> T computeAfterLSTManagerUpdate(@NotNull Project project, @NotNull final Computable<T> computation) {
494     assert !ApplicationManager.getApplication().isDispatchThread();
495     FutureResult<T> ref = new FutureResult<>();
496     LineStatusTrackerManager.getInstance(project).invokeAfterUpdate(() -> {
497       try {
498         ref.set(computation.compute());
499       }
500       catch (Throwable e) {
501         ref.setException(e);
502       }
503     });
504     try {
505       return ref.get();
506     }
507     catch (InterruptedException | ExecutionException e) {
508       return null;
509     }
510   }
511
512
513   @NotNull
514   private List<CommitChange> addCaseOnlyRenamesToIndex(@NotNull GitRepository repository,
515                                                        @NotNull Collection<? extends CommitChange> changes,
516                                                        @NotNull Set<CommitChange> alreadyProcessed,
517                                                        @NotNull List<? super VcsException> exceptions) {
518     if (SystemInfo.isFileSystemCaseSensitive) return Collections.emptyList();
519
520     List<CommitChange> caseOnlyRenames = filter(changes, change -> !alreadyProcessed.contains(change) && isCaseOnlyRename(change));
521     if (caseOnlyRenames.isEmpty()) return Collections.emptyList();
522
523     LOG.info("Committing case only rename: " + getLogString(repository.getRoot().getPath(), caseOnlyRenames) +
524              " in " + getShortRepositoryName(repository));
525
526     List<FilePath> pathsToAdd = map(caseOnlyRenames, it -> it.afterPath);
527     List<FilePath> pathsToDelete = map(caseOnlyRenames, it -> it.beforePath);
528
529     LOG.debug(String.format("Updating index for case only changes: added: %s,\n removed: %s", pathsToAdd, pathsToDelete));
530     updateIndex(myProject, repository.getRoot(), pathsToAdd, pathsToDelete, exceptions);
531
532     return caseOnlyRenames;
533   }
534
535   private static boolean isCaseOnlyRename(@NotNull ChangedPath change) {
536     if (SystemInfo.isFileSystemCaseSensitive) return false;
537     if (!change.isMove()) return false;
538     FilePath afterPath = Objects.requireNonNull(change.afterPath);
539     FilePath beforePath = Objects.requireNonNull(change.beforePath);
540     return isCaseOnlyChange(beforePath.getPath(), afterPath.getPath());
541   }
542
543   @NotNull
544   private static List<FilePath> getPaths(@NotNull Collection<? extends CommitChange> changes) {
545     List<FilePath> files = new ArrayList<>();
546     for (CommitChange change : changes) {
547       if (CASE_SENSITIVE_FILE_PATH_HASHING_STRATEGY.equals(change.beforePath, change.afterPath)) {
548         addIfNotNull(files, change.beforePath);
549       }
550       else {
551         addIfNotNull(files, change.beforePath);
552         addIfNotNull(files, change.afterPath);
553       }
554     }
555     return files;
556   }
557
558   private static void processExcludedPaths(@NotNull Collection<? extends GitDiffChange> changes,
559                                            @NotNull Set<FilePath> added,
560                                            @NotNull Set<FilePath> removed,
561                                            @NotNull PairConsumer<? super FilePath, ? super FilePath> function) {
562     for (GitDiffChange change : changes) {
563       FilePath before = change.getBeforePath();
564       FilePath after = change.getAfterPath();
565       if (removed.contains(before)) before = null;
566       if (added.contains(after)) after = null;
567       function.consume(before, after);
568     }
569   }
570
571   @NonNls
572   @NotNull
573   private static String getLogString(@NotNull String root, @NotNull Collection<? extends ChangedPath> changes) {
574     return GitUtil.getLogString(root, changes, it -> it.beforePath, it -> it.afterPath);
575   }
576
577   @NotNull
578   private Pair<Collection<CommitChange>, List<VcsException>> commitExplicitRenames(@NotNull GitRepository repository,
579                                                                                    @NotNull Collection<CommitChange> changes,
580                                                                                    @NotNull @NonNls String message) {
581     List<GitCheckinExplicitMovementProvider> providers =
582       filter(GitCheckinExplicitMovementProvider.EP_NAME.getExtensions(), it -> it.isEnabled(myProject));
583
584     List<VcsException> exceptions = new ArrayList<>();
585     VirtualFile root = repository.getRoot();
586
587     List<FilePath> beforePaths = mapNotNull(changes, it -> it.beforePath);
588     List<FilePath> afterPaths = mapNotNull(changes, it -> it.afterPath);
589
590     Set<Movement> movedPaths = new HashSet<>();
591     for (GitCheckinExplicitMovementProvider provider : providers) {
592       Collection<Movement> providerMovements = provider.collectExplicitMovements(myProject, beforePaths, afterPaths);
593       if (!providerMovements.isEmpty()) {
594         message = provider.getCommitMessage(message);
595         movedPaths.addAll(providerMovements);
596       }
597     }
598
599     try {
600       Pair<List<CommitChange>, List<CommitChange>> committedAndNewChanges = addExplicitMovementsToIndex(repository, changes, movedPaths);
601       if (committedAndNewChanges == null) return Pair.create(changes, exceptions);
602
603       List<CommitChange> movedChanges = committedAndNewChanges.first;
604       Collection<CommitChange> newRootChanges = committedAndNewChanges.second;
605
606       runWithMessageFile(myProject, root, message, moveMessageFile -> exceptions.addAll(commitUsingIndex(repository, movedChanges, new HashSet<>(movedChanges), moveMessageFile)));
607
608       List<Couple<FilePath>> committedMovements = mapNotNull(movedChanges, it -> Couple.of(it.beforePath, it.afterPath));
609       for (GitCheckinExplicitMovementProvider provider : providers) {
610         provider.afterMovementsCommitted(myProject, committedMovements);
611       }
612
613       return Pair.create(newRootChanges, exceptions);
614     }
615     catch (VcsException e) {
616       exceptions.add(e);
617       return Pair.create(changes, exceptions);
618     }
619   }
620
621   @Nullable
622   private Pair<List<CommitChange>, List<CommitChange>> addExplicitMovementsToIndex(@NotNull GitRepository repository,
623                                                                                    @NotNull Collection<? extends CommitChange> changes,
624                                                                                    @NotNull Collection<? extends Movement> explicitMoves)
625     throws VcsException {
626     explicitMoves = filterExcludedChanges(explicitMoves, changes);
627     if (explicitMoves.isEmpty()) return null;
628     LOG.info("Committing explicit rename: " + explicitMoves + " in " + getShortRepositoryName(repository));
629
630     Map<FilePath, Movement> movesMap = new HashMap<>();
631     for (Movement move : explicitMoves) {
632       movesMap.put(move.getBefore(), move);
633       movesMap.put(move.getAfter(), move);
634     }
635
636
637     List<CommitChange> nextCommitChanges = new ArrayList<>();
638     List<CommitChange> movedChanges = new ArrayList<>();
639
640     Map<FilePath, CommitChange> affectedBeforePaths = new HashMap<>();
641     Map<FilePath, CommitChange> affectedAfterPaths = new HashMap<>();
642     for (CommitChange change : changes) {
643       if (!movesMap.containsKey(change.beforePath) &&
644           !movesMap.containsKey(change.afterPath)) {
645         nextCommitChanges.add(change); // is not affected by explicit move
646       }
647       else {
648         if (change.beforePath != null) affectedBeforePaths.put(change.beforePath, change);
649         if (change.afterPath != null) affectedAfterPaths.put(change.afterPath, change);
650       }
651     }
652
653
654     List<FilePath> pathsToDelete = map(explicitMoves, move -> move.getBefore());
655     LOG.debug(String.format("Updating index for explicit movements: removing: %s", pathsToDelete));
656     GitFileUtils.deletePaths(myProject, repository.getRoot(), pathsToDelete, "--ignore-unmatch");
657
658
659     for (Movement move : explicitMoves) {
660       FilePath beforeFilePath = move.getBefore();
661       FilePath afterFilePath = move.getAfter();
662       CommitChange bChange = Objects.requireNonNull(affectedBeforePaths.get(beforeFilePath));
663       CommitChange aChange = Objects.requireNonNull(affectedAfterPaths.get(afterFilePath));
664
665       if (bChange.beforeRevision == null) {
666         LOG.warn(String.format("Unknown before revision: %s, %s", bChange, aChange));
667         continue;
668       }
669
670       GitIndexUtil.StagedFile stagedFile = GitIndexUtil.listTree(repository, beforeFilePath, bChange.beforeRevision);
671       if (stagedFile == null) {
672         LOG.warn(String.format("Can't get revision for explicit move: %s -> %s", beforeFilePath, afterFilePath));
673         continue;
674       }
675
676       LOG.debug(String.format("Updating index for explicit movements: adding movement: %s -> %s", beforeFilePath, afterFilePath));
677       Hash hash = HashImpl.build(stagedFile.getBlobHash());
678       boolean isExecutable = stagedFile.isExecutable();
679       GitIndexUtil.updateIndex(repository, afterFilePath, hash, isExecutable);
680
681       // We do not use revision numbers after, and it's unclear which numbers should be used. For now, just pass null values.
682       nextCommitChanges.add(new CommitChange(afterFilePath, afterFilePath,
683                                              null, null,
684                                              aChange.changelistId, aChange.virtualFile));
685       movedChanges.add(new CommitChange(beforeFilePath, afterFilePath,
686                                         null, null,
687                                         null, null));
688
689       affectedBeforePaths.remove(beforeFilePath);
690       affectedAfterPaths.remove(afterFilePath);
691     }
692
693     // Commit leftovers as added/deleted files (ex: if git detected files movements in a conflicting way)
694     affectedBeforePaths.forEach((bPath, change) -> nextCommitChanges.add(new CommitChange(change.beforePath, null,
695                                                                                         change.beforeRevision, null,
696                                                                                         change.changelistId, change.virtualFile)));
697     affectedAfterPaths.forEach((aPath, change) -> nextCommitChanges.add(new CommitChange(null, change.afterPath,
698                                                                                        null, change.afterRevision,
699                                                                                        change.changelistId, change.virtualFile)));
700
701     if (movedChanges.isEmpty()) return null;
702     return Pair.create(movedChanges, nextCommitChanges);
703   }
704
705   @NotNull
706   private static List<Movement> filterExcludedChanges(@NotNull Collection<? extends Movement> explicitMoves,
707                                                       @NotNull Collection<? extends CommitChange> changes) {
708     HashMultiset<FilePath> movedPathsMultiSet = HashMultiset.create();
709     for (Movement move : explicitMoves) {
710       movedPathsMultiSet.add(move.getBefore());
711       movedPathsMultiSet.add(move.getAfter());
712     }
713
714     HashMultiset<FilePath> beforePathsMultiSet = HashMultiset.create();
715     HashMultiset<FilePath> afterPathsMultiSet = HashMultiset.create();
716     for (CommitChange change : changes) {
717       addIfNotNull(beforePathsMultiSet, change.beforePath);
718       addIfNotNull(afterPathsMultiSet, change.afterPath);
719     }
720     return filter(explicitMoves, move -> movedPathsMultiSet.count(move.getBefore()) == 1 && movedPathsMultiSet.count(move.getAfter()) == 1 &&
721            beforePathsMultiSet.count(move.getBefore()) == 1 && afterPathsMultiSet.count(move.getAfter()) == 1 &&
722            beforePathsMultiSet.count(move.getAfter()) == 0 && afterPathsMultiSet.count(move.getBefore()) == 0);
723   }
724
725
726   private static void resetExcluded(@NotNull Project project,
727                                     @NotNull VirtualFile root,
728                                     @NotNull Collection<? extends ChangedPath> changes) throws VcsException {
729     Set<FilePath> allPaths = new THashSet<>(CASE_SENSITIVE_FILE_PATH_HASHING_STRATEGY);
730     for (ChangedPath change : changes) {
731       addIfNotNull(allPaths, change.afterPath);
732       addIfNotNull(allPaths, change.beforePath);
733     }
734
735     for (List<String> paths : VcsFileUtil.chunkPaths(root, allPaths)) {
736       GitLineHandler handler = new GitLineHandler(project, root, GitCommand.RESET);
737       handler.endOptions();
738       handler.addParameters(paths);
739       Git.getInstance().runCommand(handler).throwOnError();
740     }
741   }
742
743   private static void restoreExcluded(@NotNull Project project,
744                                       @NotNull VirtualFile root,
745                                       @NotNull Collection<? extends ChangedPath> changes,
746                                       @NotNull Set<FilePath> unstagedDeletions) {
747     List<VcsException> restoreExceptions = new ArrayList<>();
748
749     Set<FilePath> toAdd = new HashSet<>();
750     Set<FilePath> toRemove = new HashSet<>();
751
752     for (ChangedPath change : changes) {
753       if (addAsCaseOnlyRename(project, root, change, restoreExceptions)) continue;
754
755       if (change.beforePath == null && unstagedDeletions.contains(change.afterPath)) {
756         // we can't restore ADDED-DELETED files
757         LOG.info("Ignored added-deleted staged change in " + change.afterPath);
758         continue;
759       }
760
761       addIfNotNull(toAdd, change.afterPath);
762       addIfNotNull(toRemove, change.beforePath);
763     }
764     toRemove.removeAll(toAdd);
765
766     LOG.debug(String.format("Restoring staged changes after commit: added: %s, removed: %s", toAdd, toRemove));
767     updateIndex(project, root, toAdd, toRemove, restoreExceptions);
768
769     for (VcsException e : restoreExceptions) {
770       LOG.warn(e);
771     }
772   }
773
774   private static boolean addAsCaseOnlyRename(@NotNull Project project, @NotNull VirtualFile root, @NotNull ChangedPath change,
775                                              @NotNull List<? super VcsException> exceptions) {
776     try {
777       if (!isCaseOnlyRename(change)) return false;
778
779       FilePath beforePath = Objects.requireNonNull(change.beforePath);
780       FilePath afterPath = Objects.requireNonNull(change.afterPath);
781
782       LOG.debug(String.format("Restoring staged case-only rename after commit: %s", change));
783       GitLineHandler h = new GitLineHandler(project, root, GitCommand.MV);
784       h.addParameters("-f", beforePath.getPath(), afterPath.getPath());
785       Git.getInstance().runCommandWithoutCollectingOutput(h).throwOnError();
786       return true;
787     }
788     catch (VcsException e) {
789       exceptions.add(e);
790       return false;
791     }
792   }
793
794   private boolean mergeCommit(@NotNull Project project,
795                               @NotNull VirtualFile root,
796                               @NotNull Collection<? extends CommitChange> rootChanges,
797                               @NotNull File messageFile,
798                               @NotNull List<? super VcsException> exceptions,
799                               @NotNull PartialOperation partialOperation) {
800     Set<FilePath> added = map2SetNotNull(rootChanges, it -> it.afterPath);
801     Set<FilePath> removed = map2SetNotNull(rootChanges, it -> it.beforePath);
802     removed.removeAll(added);
803
804     HashSet<FilePath> realAdded = new HashSet<>();
805     HashSet<FilePath> realRemoved = new HashSet<>();
806     // perform diff
807     GitLineHandler diff = new GitLineHandler(project, root, GitCommand.DIFF);
808     diff.setSilent(true);
809     diff.setStdoutSuppressed(true);
810     diff.addParameters("--diff-filter=ADMRUX", "--name-status", "--no-renames", "HEAD");
811     diff.endOptions();
812     String output;
813     try {
814       output = Git.getInstance().runCommand(diff).getOutputOrThrow();
815     }
816     catch (VcsException ex) {
817       exceptions.add(ex);
818       return false;
819     }
820     String rootPath = root.getPath();
821     for (StringTokenizer lines = new StringTokenizer(output, "\n", false); lines.hasMoreTokens(); ) {
822       String line = lines.nextToken().trim();
823       if (line.length() == 0) {
824         continue;
825       }
826       String[] tk = line.split("\t");
827       switch (tk[0].charAt(0)) {
828         case 'M':
829         case 'A':
830           realAdded.add(VcsUtil.getFilePath(rootPath + "/" + tk[1]));
831           break;
832         case 'D':
833           realRemoved.add(VcsUtil.getFilePath(rootPath + "/" + tk[1], false));
834           break;
835         default:
836           throw new IllegalStateException("Unexpected status: " + line);
837       }
838     }
839     realAdded.removeAll(added);
840     realRemoved.removeAll(removed);
841     if (realAdded.size() != 0 || realRemoved.size() != 0) {
842
843       final List<FilePath> files = new ArrayList<>();
844       files.addAll(realAdded);
845       files.addAll(realRemoved);
846       Ref<Boolean> mergeAll = new Ref<>();
847       try {
848         ApplicationManager.getApplication().invokeAndWait(() -> {
849           String message = GitBundle.message("commit.partial.merge.message", partialOperation.getName());
850           SelectFilePathsDialog dialog = new SelectFilePathsDialog(project, files, message, null, "Commit All Files",
851                                                                    CommonBundle.getCancelButtonText(), false);
852           dialog.setTitle(GitBundle.getString("commit.partial.merge.title"));
853           dialog.show();
854           mergeAll.set(dialog.isOK());
855         });
856       }
857       catch (RuntimeException ex) {
858         throw ex;
859       }
860       catch (Exception ex) {
861         throw new RuntimeException("Unable to invoke a message box on AWT thread", ex);
862       }
863       if (!mergeAll.get()) {
864         return false;
865       }
866       // update non-indexed files
867       if (!updateIndex(project, root, realAdded, realRemoved, exceptions)) {
868         return false;
869       }
870       for (FilePath f : realAdded) {
871         VcsDirtyScopeManager.getInstance(project).fileDirty(f);
872       }
873       for (FilePath f : realRemoved) {
874         VcsDirtyScopeManager.getInstance(project).fileDirty(f);
875       }
876     }
877     // perform merge commit
878     try {
879       commitWithoutPaths(project, root, messageFile);
880     }
881     catch (VcsException ex) {
882       exceptions.add(ex);
883       return false;
884     }
885     return true;
886   }
887
888   private void commitWithoutPaths(@NotNull Project project,
889                                   @NotNull VirtualFile root,
890                                   @NotNull File messageFile) throws VcsException {
891     GitLineHandler handler = new GitLineHandler(project, root, GitCommand.COMMIT);
892     handler.setStdoutSuppressed(false);
893     handler.addParameters("-F");
894     handler.addAbsoluteFile(messageFile);
895     if (myNextCommitAmend) {
896       handler.addParameters("--amend");
897     }
898     if (myNextCommitAuthor != null) {
899       handler.addParameters("--author=" + myNextCommitAuthor);
900     }
901     if (myNextCommitAuthorDate != null) {
902       handler.addParameters("--date", COMMIT_DATE_FORMAT.format(myNextCommitAuthorDate));
903     }
904     if (myNextCommitSignOff) {
905       handler.addParameters("--signoff");
906     }
907     if (myNextCommitSkipHook) {
908       handler.addParameters("--no-verify");
909     }
910     handler.endOptions();
911     Git.getInstance().runCommand(handler).throwOnError();
912   }
913
914   /**
915    * Check if commit has failed due to unfinished merge or cherry-pick.
916    *
917    * @param ex an exception to examine
918    * @return true if exception means that there is a partial commit during merge
919    */
920   private static PartialOperation isMergeCommit(final VcsException ex) {
921     String message = ex.getMessage();
922     if (message.contains("cannot do a partial commit during a merge")) {
923       return PartialOperation.MERGE;
924     }
925     if (message.contains("cannot do a partial commit during a cherry-pick")) {
926       return PartialOperation.CHERRY_PICK;
927     }
928     return PartialOperation.NONE;
929   }
930
931   /**
932    * Update index (delete and remove files)
933    *
934    * @param project    the project
935    * @param root       a vcs root
936    * @param added      added/modified files to commit
937    * @param removed    removed files to commit
938    * @param exceptions a list of exceptions to update
939    * @return true if index was updated successfully
940    */
941   private static boolean updateIndex(final Project project,
942                                      final VirtualFile root,
943                                      final Collection<? extends FilePath> added,
944                                      final Collection<? extends FilePath> removed,
945                                      final List<? super VcsException> exceptions) {
946     boolean rc = true;
947     if (!removed.isEmpty()) {
948       try {
949         GitFileUtils.deletePaths(project, root, removed, "--ignore-unmatch", "--cached", "-r");
950       }
951       catch (VcsException ex) {
952         exceptions.add(ex);
953         rc = false;
954       }
955     }
956     if (!added.isEmpty()) {
957       try {
958         GitFileUtils.addPathsForce(project, root, added);
959       }
960       catch (VcsException ex) {
961         exceptions.add(ex);
962         rc = false;
963       }
964     }
965     return rc;
966   }
967
968   /**
969    * Create a file that contains the specified message
970    *
971    * @param root    a git repository root
972    * @param message a message to write
973    * @return a file reference
974    * @throws IOException if file cannot be created
975    */
976   @NotNull
977   public static File createCommitMessageFile(@NotNull Project project, @NotNull VirtualFile root, @NotNull @NonNls String message)
978     throws IOException {
979     // filter comment lines
980     File file = FileUtil.createTempFile(GIT_COMMIT_MSG_FILE_PREFIX, GIT_COMMIT_MSG_FILE_EXT);
981     //noinspection SSBasedInspection
982     file.deleteOnExit();
983     @NonNls String encoding = GitConfigUtil.getCommitEncoding(project, root);
984     try (Writer out = new OutputStreamWriter(new FileOutputStream(file), encoding)) {
985       out.write(message);
986     }
987     return file;
988   }
989
990   public static void runWithMessageFile(@NotNull Project project, @NotNull VirtualFile root, @NotNull @NonNls String message,
991                                         @NotNull ThrowableConsumer<? super File, ? extends VcsException> task) throws VcsException {
992     File messageFile;
993     try {
994       messageFile = createCommitMessageFile(project, root, message);
995     }
996     catch (IOException ex) {
997       throw new VcsException("Creation of commit message file failed", ex);
998     }
999
1000     try {
1001       task.consume(messageFile);
1002     }
1003     finally {
1004       if (!messageFile.delete()) {
1005         LOG.warn("Failed to remove temporary file: " + messageFile);
1006       }
1007     }
1008   }
1009
1010   @Override
1011   public List<VcsException> scheduleMissingFileForDeletion(@NotNull List<? extends FilePath> files) {
1012     ArrayList<VcsException> rc = new ArrayList<>();
1013     Map<VirtualFile, List<FilePath>> sortedFiles;
1014     try {
1015       sortedFiles = sortFilePathsByGitRoot(myProject, files);
1016     }
1017     catch (VcsException e) {
1018       rc.add(e);
1019       return rc;
1020     }
1021     for (Map.Entry<VirtualFile, List<FilePath>> e : sortedFiles.entrySet()) {
1022       try {
1023         final VirtualFile root = e.getKey();
1024         GitFileUtils.deletePaths(myProject, root, e.getValue());
1025         markRootDirty(root);
1026       }
1027       catch (VcsException ex) {
1028         rc.add(ex);
1029       }
1030     }
1031     return rc;
1032   }
1033
1034   private void commit(@NotNull Project project, @NotNull VirtualFile root, @NotNull Collection<? extends FilePath> files, @NotNull File messageFile)
1035     throws VcsException {
1036     boolean amend = myNextCommitAmend;
1037     for (List<String> paths : VcsFileUtil.chunkPaths(root, files)) {
1038       GitLineHandler handler = new GitLineHandler(project, root, GitCommand.COMMIT);
1039       handler.setStdoutSuppressed(false);
1040       if (myNextCommitSignOff) {
1041         handler.addParameters("--signoff");
1042       }
1043       if (amend) {
1044         handler.addParameters("--amend");
1045       }
1046       else {
1047         amend = true;
1048       }
1049       if (myNextCommitSkipHook) {
1050         handler.addParameters("--no-verify");
1051       }
1052       handler.addParameters("--only");
1053       handler.addParameters("-F");
1054       handler.addAbsoluteFile(messageFile);
1055       if (myNextCommitAuthor != null) {
1056         handler.addParameters("--author=" + myNextCommitAuthor);
1057       }
1058       if (myNextCommitAuthorDate != null) {
1059         handler.addParameters("--date", COMMIT_DATE_FORMAT.format(myNextCommitAuthorDate));
1060       }
1061       handler.endOptions();
1062       handler.addParameters(paths);
1063       Git.getInstance().runCommand(handler).throwOnError();
1064     }
1065   }
1066
1067   @Override
1068   public List<VcsException> scheduleUnversionedFilesForAddition(@NotNull List<? extends VirtualFile> files) {
1069     ArrayList<VcsException> rc = new ArrayList<>();
1070     Map<VirtualFile, List<VirtualFile>> sortedFiles;
1071     try {
1072       sortedFiles = sortFilesByGitRoot(myProject, files);
1073     }
1074     catch (VcsException e) {
1075       rc.add(e);
1076       return rc;
1077     }
1078     for (Map.Entry<VirtualFile, List<VirtualFile>> e : sortedFiles.entrySet()) {
1079       try {
1080         final VirtualFile root = e.getKey();
1081         GitFileUtils.addFiles(myProject, root, e.getValue());
1082         markRootDirty(root);
1083       }
1084       catch (VcsException ex) {
1085         rc.add(ex);
1086       }
1087     }
1088     return rc;
1089   }
1090
1091   private enum PartialOperation {
1092     NONE("none"),
1093     MERGE("merge"),
1094     CHERRY_PICK("cherry-pick");
1095
1096     private final @Nls String myName;
1097
1098     PartialOperation(@Nls String name) {
1099       myName = name;
1100     }
1101
1102     @Nls
1103     String getName() {
1104       return myName;
1105     }
1106   }
1107
1108   @NotNull
1109   private static Map<GitRepository, Collection<Change>> sortChangesByGitRoot(@NotNull Project project,
1110                                                                              @NotNull List<? extends Change> changes,
1111                                                                              @NotNull List<? super VcsException> exceptions) {
1112     Map<GitRepository, Collection<Change>> result = new HashMap<>();
1113     for (Change change : changes) {
1114       try {
1115         // note that any path will work, because changes could happen within single vcs root
1116         final FilePath filePath = getFilePath(change);
1117
1118         // the parent paths for calculating roots in order to account for submodules that contribute
1119         // to the parent change. The path "." is never is valid change, so there should be no problem
1120         // with it.
1121         GitRepository repository = getRepositoryForFile(project, Objects.requireNonNull(filePath.getParentPath()));
1122         Collection<Change> changeList = result.computeIfAbsent(repository, key -> new ArrayList<>());
1123         changeList.add(change);
1124       }
1125       catch (VcsException e) {
1126         exceptions.add(e);
1127       }
1128     }
1129     return result;
1130   }
1131
1132   private void markRootDirty(final VirtualFile root) {
1133     // Note that the root is invalidated because changes are detected per-root anyway.
1134     // Otherwise it is not possible to detect moves.
1135     VcsDirtyScopeManager.getInstance(myProject).dirDirtyRecursively(root);
1136   }
1137
1138   @SuppressWarnings("InnerClassMayBeStatic") // used by external plugins
1139   public class GitCheckinOptions implements CheckinChangeListSpecificComponent, RefreshableOnComponent, Disposable {
1140     @NotNull private final GitCommitOptionsUi myOptionsUi;
1141
1142     GitCheckinOptions(@NotNull CheckinProjectPanel commitPanel, @NotNull CommitContext commitContext, boolean showAmendOption) {
1143       myOptionsUi = new GitCommitOptionsUi(commitPanel, commitContext, showAmendOption);
1144       Disposer.register(this, myOptionsUi);
1145     }
1146
1147     @SuppressWarnings("unused") // used by external plugins
1148     @Nullable
1149     public String getAuthor() {
1150       VcsUser author = myOptionsUi.getAuthor();
1151       return author != null ? author.toString() : null;
1152     }
1153
1154     @SuppressWarnings("unused") // used by external plugins
1155     public boolean isAmend() {
1156       return myOptionsUi.getAmendHandler().isAmendCommitMode();
1157     }
1158
1159     @Override
1160     public JComponent getComponent() {
1161       return myOptionsUi.getComponent();
1162     }
1163
1164     @Override
1165     public void restoreState() {
1166       myOptionsUi.restoreState();
1167     }
1168
1169     @Override
1170     public void refresh() {
1171       myOptionsUi.refresh();
1172     }
1173
1174     @Override
1175     public void saveState() {
1176       myOptionsUi.saveState();
1177     }
1178
1179     @Override
1180     public void onChangeListSelected(@NotNull LocalChangeList list) {
1181       myOptionsUi.onChangeListSelected(list);
1182     }
1183
1184     @Override
1185     public void dispose() {
1186     }
1187   }
1188
1189   @NotNull
1190   static List<GitCheckinExplicitMovementProvider> collectActiveMovementProviders(@NotNull Project project) {
1191     GitCheckinExplicitMovementProvider[] allProviders = GitCheckinExplicitMovementProvider.EP_NAME.getExtensions();
1192     List<GitCheckinExplicitMovementProvider> enabledProviders = filter(allProviders, it -> it.isEnabled(project));
1193     if (enabledProviders.isEmpty()) return Collections.emptyList();
1194
1195     List<CommitChange> changes = map(ChangeListManager.getInstance(project).getAllChanges(), CommitChange::new);
1196     List<FilePath> beforePaths = mapNotNull(changes, it -> it.beforePath);
1197     List<FilePath> afterPaths = mapNotNull(changes, it -> it.afterPath);
1198
1199     return filter(enabledProviders, it -> {
1200       Collection<Movement> movements = it.collectExplicitMovements(project, beforePaths, afterPaths);
1201       List<Movement> filteredMovements = filterExcludedChanges(movements, changes);
1202       return !filteredMovements.isEmpty();
1203     });
1204   }
1205
1206   private static class ChangedPath {
1207     @Nullable public final FilePath beforePath;
1208     @Nullable public final FilePath afterPath;
1209
1210     ChangedPath(@Nullable FilePath beforePath,
1211                 @Nullable FilePath afterPath) {
1212       assert beforePath != null || afterPath != null;
1213       this.beforePath = beforePath;
1214       this.afterPath = afterPath;
1215     }
1216
1217     public boolean isMove() {
1218       if (beforePath == null || afterPath == null) return false;
1219       return !CASE_SENSITIVE_FILE_PATH_HASHING_STRATEGY.equals(beforePath, afterPath);
1220     }
1221
1222     @Override
1223     public String toString() {
1224       return String.format("%s -> %s", beforePath, afterPath);
1225     }
1226   }
1227
1228   private static class CommitChange extends ChangedPath {
1229     @Nullable public final VcsRevisionNumber beforeRevision;
1230     @Nullable public final VcsRevisionNumber afterRevision;
1231
1232     @Nullable public final String changelistId;
1233     @Nullable public final VirtualFile virtualFile;
1234
1235     CommitChange(@NotNull Change change) {
1236       super(getBeforePath(change), getAfterPath(change));
1237
1238       ContentRevision bRev = change.getBeforeRevision();
1239       ContentRevision aRev = change.getAfterRevision();
1240       this.beforeRevision = bRev != null ? bRev.getRevisionNumber() : null;
1241       this.afterRevision = aRev != null ? aRev.getRevisionNumber() : null;
1242
1243       if (change instanceof ChangeListChange) {
1244         this.changelistId = ((ChangeListChange)change).getChangeListId();
1245       }
1246       else {
1247         this.changelistId = null;
1248       }
1249
1250       if (aRev instanceof CurrentContentRevision) {
1251         this.virtualFile = ((CurrentContentRevision)aRev).getVirtualFile();
1252       }
1253       else {
1254         this.virtualFile = null;
1255       }
1256     }
1257
1258     CommitChange(@Nullable FilePath beforePath,
1259                  @Nullable FilePath afterPath,
1260                  @Nullable VcsRevisionNumber beforeRevision,
1261                  @Nullable VcsRevisionNumber afterRevision,
1262                  @Nullable String changelistId,
1263                  @Nullable VirtualFile virtualFile) {
1264       super(beforePath, afterPath);
1265       this.beforeRevision = beforeRevision;
1266       this.afterRevision = afterRevision;
1267       this.changelistId = changelistId;
1268       this.virtualFile = virtualFile;
1269     }
1270
1271     @NonNls
1272     @Override
1273     public String toString() {
1274       return super.toString() + ", changelist: " + changelistId;
1275     }
1276   }
1277 }